Compare commits
191 Commits
a77a8a3a98
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b27d4ba6ff | |||
| 6da48f88c1 | |||
| 516141b41d | |||
| 4f70290af5 | |||
| 3fa159ff2a | |||
| 143248ff0f | |||
| 56c94ac2f4 | |||
| 255ac6525e | |||
| 10e37e749b | |||
| f23990a4d9 | |||
| 62b83b46a4 | |||
| f8b2429533 | |||
| 3883927be0 | |||
| 39e02f0d9b | |||
| 29593f4c61 | |||
| 220f7e3a08 | |||
| 258aa6a34b | |||
| 51bcc9f874 | |||
| eafa086c73 | |||
| ab2daf99bd | |||
| 1cf9fea40a | |||
| cd4f83f2cb | |||
| 457350908a | |||
| e759282116 | |||
| 1df1b2bfca | |||
| 51a2114e02 | |||
| 21e4ac5124 | |||
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e | |||
| adc36246b8 | |||
| dd9dc04328 | |||
| 4a60d75a13 | |||
| e98eddc168 | |||
| 8cd09f3f89 | |||
| 4c1608f78a | |||
| 24219e4d9a | |||
| fde58bea06 | |||
| 52b78ce346 | |||
| f804ff8442 | |||
| d9abb275a5 | |||
| 4b56eb7ab1 | |||
| 27ac7f3e28 | |||
| dfd42c1b10 | |||
| 297b8a8d5a | |||
| 91fb4b0757 | |||
| f4386e97ee | |||
| e8c9fc7e7d | |||
| d591200df8 | |||
| 83af32eb88 | |||
| 2a49e3d30f | |||
| 6e40e16017 | |||
| dd09bcaeec | |||
| 013eafd775 | |||
| 07cd66a0e3 | |||
| 73d453d78a | |||
| d4e9fed719 | |||
| 3e93f64c6b | |||
| 377d2d3ae8 | |||
| b51f9e8e30 | |||
| d380437594 | |||
| cff0af31be | |||
| e492e5f71c | |||
| 9a5b7dd061 | |||
| b3051b423a | |||
| bc951a36d9 | |||
| 2e043260eb | |||
| 1828ac85eb | |||
| 50a4fc38a7 | |||
| 30f3dae5a3 | |||
| 4c750f0268 | |||
| 59b0d8977a | |||
| 2bc03ed97c | |||
| 91963f3b87 | |||
| 3ae0b579d3 | |||
| 972ee1e5d0 | |||
| 70f2803dd3 | |||
| a247622d23 | |||
| 50d50fcbd0 | |||
| b306a5e8f4 | |||
| 28b08580c8 | |||
| 754bfca87d | |||
| 1decb4572c | |||
| d685341b04 | |||
| 0c6d8409c7 | |||
| f81851445e | |||
| 4748368809 | |||
| f310363f7c | |||
| 95f0eac079 | |||
| 11dcfdad73 | |||
| 01f7add8dd | |||
| 0d1007282a | |||
| 2a15c14ee8 | |||
| bc5e227d81 | |||
| 8a70259445 | |||
| 823935c016 | |||
| dab5560de8 | |||
| 157b4c6ec3 | |||
| 211c46ebbc | |||
| d81e9a3fa4 | |||
| fd0de714a4 | |||
| c6b155520c | |||
| 66b77e747d | |||
| 71b5eb1758 | |||
| b4f01210d9 | |||
| 9bceeaac9c | |||
| 332960de30 | |||
| 0455e63a2e | |||
| aaed1b2d01 | |||
| 9dee534b2f | |||
| beef3ce76b | |||
| 884a694718 | |||
| 4cafbe9610 | |||
| 19923ed26b | |||
| 46f8d227b8 | |||
| 95e4956216 | |||
| 77e520bbce | |||
| 518bace534 | |||
| fcde2d68fc | |||
| 5a33f68743 | |||
| 040cbd1962 | |||
| b679c9687d | |||
| 314360a394 | |||
| 44a0c38016 | |||
| da9e1ab293 | |||
| 5de297a804 | |||
| 4429674100 | |||
| 316ec42566 | |||
| 894832c62b | |||
| 1d90bfe044 | |||
| ce0caa5685 | |||
| 33f823aba0 | |||
| edd55cd2fd | |||
| f3344b2859 | |||
| 1107de989b | |||
| a423bcf03e | |||
| 661547f6cf | |||
| 3015a490f9 | |||
| 5b4ed79f87 | |||
| 52a5f941fe | |||
| 6161d69ba2 | |||
| f41f72b86f | |||
| 644bf158cd | |||
| f89c0382f0 | |||
| 11b8e31a29 | |||
| 0ddef13124 | |||
| 60bed05d3f | |||
| 40da2d6b11 | |||
| d96e0ea1b4 | |||
| 7d652716bb | |||
| b6047f5b7d | |||
| 366d4b9765 | |||
| 540205402f | |||
| 07fab01f6a | |||
| 6c07f6cbb2 | |||
| bc7431943a | |||
| adec17cd02 | |||
| a28d5d1de5 | |||
| 502473eee4 | |||
| 183f55c7b3 | |||
| 169a774b9c | |||
| ebbe6d62b8 | |||
| c2c0e3c740 | |||
| 4a1f71a312 | |||
| 5dd5e01dc6 | |||
| 694a1cd1a5 | |||
| 826ef2ddd2 | |||
| a1cc05cd3d | |||
| 19d267587b | |||
| 9a13aee8ed | |||
| 9c39a9703f | |||
| 395707951e | |||
| 34bf961309 | |||
| 44acf5e442 | |||
| b3224ba13d | |||
| 93b7279c3a | |||
| 29d942322d | |||
| 8c8975239a | |||
| f766a72480 | |||
| 618376aa39 | |||
| efca9734d2 | |||
| 6acd783754 | |||
| 8cf5da6914 | |||
| eee33d6a1b | |||
| aefca3115e | |||
| 319900623a |
@@ -111,11 +111,9 @@ language_rules:
|
|||||||
function languageSelector(currentLang, enabledLanguages) { ... }
|
function languageSelector(currentLang, enabledLanguages) { ... }
|
||||||
window.languageSelector = languageSelector;
|
window.languageSelector = languageSelector;
|
||||||
pattern:
|
pattern:
|
||||||
file_pattern: "static/shop/js/shop-layout.js"
|
file_patterns:
|
||||||
required_patterns:
|
- "static/shop/js/shop-layout.js"
|
||||||
- "function languageSelector"
|
- "static/vendor/js/init-alpine.js"
|
||||||
- "window.languageSelector"
|
|
||||||
file_pattern: "static/vendor/js/init-alpine.js"
|
|
||||||
required_patterns:
|
required_patterns:
|
||||||
- "function languageSelector"
|
- "function languageSelector"
|
||||||
- "window.languageSelector"
|
- "window.languageSelector"
|
||||||
@@ -247,3 +245,26 @@ language_rules:
|
|||||||
pattern:
|
pattern:
|
||||||
file_pattern: "static/locales/*.json"
|
file_pattern: "static/locales/*.json"
|
||||||
check: "valid_json"
|
check: "valid_json"
|
||||||
|
|
||||||
|
- id: "LANG-011"
|
||||||
|
name: "Use $t() not I18n.t() in HTML templates"
|
||||||
|
severity: "error"
|
||||||
|
description: |
|
||||||
|
In HTML templates, never use I18n.t() directly. It evaluates once
|
||||||
|
and does NOT re-evaluate when translations finish loading async.
|
||||||
|
|
||||||
|
WRONG (non-reactive, shows raw key then updates):
|
||||||
|
<span x-text="I18n.t('module.key')"></span>
|
||||||
|
|
||||||
|
RIGHT (reactive, updates when translations load):
|
||||||
|
<span x-text="$t('module.key')"></span>
|
||||||
|
|
||||||
|
BEST (server-side, zero flash):
|
||||||
|
<span>{{ _('module.key') }}</span>
|
||||||
|
|
||||||
|
Note: I18n.t() is fine in .js files where it's called inside
|
||||||
|
async callbacks after I18n.init() has completed.
|
||||||
|
pattern:
|
||||||
|
file_pattern: "**/*.html"
|
||||||
|
anti_patterns:
|
||||||
|
- "I18n.t("
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ module_rules:
|
|||||||
en.json
|
en.json
|
||||||
de.json
|
de.json
|
||||||
fr.json
|
fr.json
|
||||||
lu.json
|
lb.json
|
||||||
|
|
||||||
Translation keys are namespaced as {module}.key_name
|
Translation keys are namespaced as {module}.key_name
|
||||||
pattern:
|
pattern:
|
||||||
@@ -269,14 +269,14 @@ module_rules:
|
|||||||
Module locales/ directory should have translation files for
|
Module locales/ directory should have translation files for
|
||||||
all supported languages to ensure consistent i18n.
|
all supported languages to ensure consistent i18n.
|
||||||
|
|
||||||
Supported languages: en, de, fr, lu
|
Supported languages: en, de, fr, lb
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
app/modules/<code>/locales/
|
app/modules/<code>/locales/
|
||||||
├── en.json
|
├── en.json
|
||||||
├── de.json
|
├── de.json
|
||||||
├── fr.json
|
├── fr.json
|
||||||
└── lu.json
|
└── lb.json
|
||||||
|
|
||||||
Missing translations will fall back to English, but it's
|
Missing translations will fall back to English, but it's
|
||||||
better to have all languages covered.
|
better to have all languages covered.
|
||||||
@@ -286,7 +286,7 @@ module_rules:
|
|||||||
- "en.json"
|
- "en.json"
|
||||||
- "de.json"
|
- "de.json"
|
||||||
- "fr.json"
|
- "fr.json"
|
||||||
- "lu.json"
|
- "lb.json"
|
||||||
|
|
||||||
- id: "MOD-007"
|
- id: "MOD-007"
|
||||||
name: "Module definition must match directory structure"
|
name: "Module definition must match directory structure"
|
||||||
|
|||||||
16
.env.example
16
.env.example
@@ -67,10 +67,15 @@ LOG_LEVEL=INFO
|
|||||||
LOG_FILE=logs/app.log
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM DOMAIN CONFIGURATION
|
# MAIN DOMAIN CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Your main platform domain
|
# Your main platform domain
|
||||||
PLATFORM_DOMAIN=wizard.lu
|
MAIN_DOMAIN=wizard.lu
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, billing redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
APP_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
# Enable/disable custom domains
|
# Enable/disable custom domains
|
||||||
@@ -223,7 +228,12 @@ R2_BACKUP_BUCKET=orion-backups
|
|||||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||||
# Get Issuer ID from https://pay.google.com/business/console
|
# Get Issuer ID from https://pay.google.com/business/console
|
||||||
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
|
||||||
|
# Path is validated at startup — file must exist and be readable, otherwise
|
||||||
|
# the app fails fast at import time.
|
||||||
|
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
|
||||||
|
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||||
|
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||||
|
|
||||||
# Apple Wallet integration (requires Apple Developer account)
|
# Apple Wallet integration (requires Apple Developer account)
|
||||||
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ jobs:
|
|||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests — unit only (integration tests run locally via make test)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 150
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -55,10 +56,9 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# act_runner executes jobs in Docker containers on the same network as services,
|
|
||||||
# so use the service name (postgres) as hostname with the internal port (5432)
|
|
||||||
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
|
LOG_LEVEL: "WARNING"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -73,8 +73,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: python -m pytest tests/ -v --tb=short
|
run: python -m pytest -m "unit" -q --tb=short --timeout=120 --no-cov --override-ini="addopts=" -p no:cacheprovider -p no:logging --durations=20
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- Brief description of what this PR does -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- [ ] Unit tests pass (`python -m pytest tests/unit/`)
|
||||||
|
- [ ] Integration tests pass (`python -m pytest tests/integration/`)
|
||||||
|
- [ ] Architecture validation passes (`python scripts/validate/validate_all.py`)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] No new warnings introduced
|
||||||
|
- [ ] Database migrations included (if applicable)
|
||||||
9
.github/dependabot.yml
vendored
Normal file
9
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -156,11 +156,10 @@ uploads/
|
|||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
|
||||||
.dockerignore.local
|
.dockerignore.local
|
||||||
*.override.yml
|
|
||||||
|
|
||||||
# Deployment & Security
|
# Deployment & Security
|
||||||
|
.build-info
|
||||||
deployment-local/
|
deployment-local/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
32
Makefile
32
Makefile
@@ -1,7 +1,7 @@
|
|||||||
# Orion Multi-Tenant E-Commerce Platform Makefile
|
# Orion Multi-Tenant E-Commerce Platform Makefile
|
||||||
# Cross-platform compatible (Windows & Linux)
|
# Cross-platform compatible (Windows & Linux)
|
||||||
|
|
||||||
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object test-db-up test-db-down test-db-reset test-db-status celery-worker celery-beat celery-dev flower celery-status celery-purge urls infra-check
|
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object test-db-up test-db-down test-db-reset test-db-status celery-worker celery-beat celery-dev flower celery-status celery-purge urls infra-check test-affected test-affected-dry
|
||||||
|
|
||||||
# Detect OS
|
# Detect OS
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -249,24 +249,21 @@ ifdef frontend
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# All testpaths (central + module tests)
|
|
||||||
TEST_PATHS := tests/ app/modules/tenancy/tests/ app/modules/catalog/tests/ app/modules/billing/tests/ app/modules/messaging/tests/ app/modules/orders/tests/ app/modules/customers/tests/ app/modules/marketplace/tests/ app/modules/inventory/tests/ app/modules/loyalty/tests/
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v $(MARKER_EXPR)
|
$(PYTHON) -m pytest -v $(MARKER_EXPR)
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
ifdef module
|
ifdef module
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "unit and $(module)"
|
$(PYTHON) -m pytest -v -m "unit and $(module)"
|
||||||
else
|
else
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m unit
|
$(PYTHON) -m pytest -v -m unit
|
||||||
endif
|
endif
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
@@ -274,29 +271,38 @@ test-integration:
|
|||||||
@sleep 2
|
@sleep 2
|
||||||
ifdef module
|
ifdef module
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "integration and $(module)"
|
$(PYTHON) -m pytest -v -m "integration and $(module)"
|
||||||
else
|
else
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m integration
|
$(PYTHON) -m pytest -v -m integration
|
||||||
endif
|
endif
|
||||||
|
|
||||||
test-coverage:
|
test-coverage:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing $(MARKER_EXPR)
|
$(PYTHON) -m pytest --cov=app --cov=models --cov=utils --cov=middleware --cov-report=html --cov-report=term-missing $(MARKER_EXPR)
|
||||||
|
|
||||||
|
test-affected:
|
||||||
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
|
@sleep 2
|
||||||
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
|
$(PYTHON) scripts/tests/run_affected_tests.py $(AFFECTED_ARGS)
|
||||||
|
|
||||||
|
test-affected-dry:
|
||||||
|
@$(PYTHON) scripts/tests/run_affected_tests.py --dry-run $(AFFECTED_ARGS)
|
||||||
|
|
||||||
test-fast:
|
test-fast:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m "not slow" $(MARKER_EXPR)
|
$(PYTHON) -m pytest -v -m "not slow" $(MARKER_EXPR)
|
||||||
|
|
||||||
test-slow:
|
test-slow:
|
||||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||||
@sleep 2
|
@sleep 2
|
||||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m slow
|
$(PYTHON) -m pytest -v -m slow
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CODE QUALITY
|
# CODE QUALITY
|
||||||
@@ -569,6 +575,8 @@ help:
|
|||||||
@echo " test-unit module=X - Run unit tests for module X"
|
@echo " test-unit module=X - Run unit tests for module X"
|
||||||
@echo " test-integration - Run integration tests only"
|
@echo " test-integration - Run integration tests only"
|
||||||
@echo " test-coverage - Run tests with coverage"
|
@echo " test-coverage - Run tests with coverage"
|
||||||
|
@echo " test-affected - Run tests for modules affected by changes"
|
||||||
|
@echo " test-affected-dry - Show affected modules without running tests"
|
||||||
@echo " test-fast - Run fast tests only"
|
@echo " test-fast - Run fast tests only"
|
||||||
@echo " test frontend=storefront - Run storefront tests"
|
@echo " test frontend=storefront - Run storefront tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
35
alembic/versions/remove_store_platform_is_primary.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Remove is_primary from store_platforms
|
||||||
|
|
||||||
|
The platform is always deterministic from the URL context (path in dev,
|
||||||
|
subdomain/domain in prod) and the JWT carries token_platform_id.
|
||||||
|
The is_primary column was a fallback picker that silently returned the
|
||||||
|
wrong platform for multi-platform stores.
|
||||||
|
|
||||||
|
Revision ID: remove_is_primary_001
|
||||||
|
Revises: billing_001
|
||||||
|
Create Date: 2026-03-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "remove_is_primary_001"
|
||||||
|
down_revision = "billing_001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("idx_store_platform_primary", table_name="store_platforms")
|
||||||
|
op.drop_column("store_platforms", "is_primary")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"store_platforms",
|
||||||
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]
|
||||||
|
)
|
||||||
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
|
||||||
|
|
||||||
|
Also converts unique constraints on users.email, users.username,
|
||||||
|
stores.store_code, stores.subdomain to partial unique indexes
|
||||||
|
that only apply to non-deleted rows.
|
||||||
|
|
||||||
|
Revision ID: softdelete_001
|
||||||
|
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "softdelete_001"
|
||||||
|
down_revision = (
|
||||||
|
"remove_is_primary_001",
|
||||||
|
"customers_002",
|
||||||
|
"dev_tools_002",
|
||||||
|
"orders_002",
|
||||||
|
"tenancy_004",
|
||||||
|
)
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables receiving soft-delete columns
|
||||||
|
SOFT_DELETE_TABLES = [
|
||||||
|
"users",
|
||||||
|
"merchants",
|
||||||
|
"stores",
|
||||||
|
"customers",
|
||||||
|
"store_users",
|
||||||
|
"orders",
|
||||||
|
"products",
|
||||||
|
"loyalty_programs",
|
||||||
|
"loyalty_cards",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ======================================================================
|
||||||
|
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
|
||||||
|
# ======================================================================
|
||||||
|
for table in SOFT_DELETE_TABLES:
|
||||||
|
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"deleted_by_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Step 2: Replace simple unique constraints with partial unique indexes
|
||||||
|
# (only enforce uniqueness among non-deleted rows)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# users.email: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
# Keep a non-unique index for lookups on all rows (including deleted)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# users.username: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"])
|
||||||
|
|
||||||
|
# stores.store_code: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"])
|
||||||
|
|
||||||
|
# stores.subdomain: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse partial unique indexes back to simple unique indexes
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# Remove soft-delete columns from all tables
|
||||||
|
for table in reversed(SOFT_DELETE_TABLES):
|
||||||
|
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||||
|
op.drop_column(table, "deleted_by_id")
|
||||||
|
op.drop_column(table, "deleted_at")
|
||||||
@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token, store mismatch, or other error
|
# Invalid token, store mismatch, or other error
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STOREFRONT MODULE GATING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def make_storefront_module_gate(module_code: str):
|
||||||
|
"""
|
||||||
|
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||||
|
|
||||||
|
Used by main.py at route registration time: each non-core module's storefront
|
||||||
|
router gets this dependency injected automatically. The framework already knows
|
||||||
|
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A FastAPI dependency function
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_module_enabled(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
from app.modules.service import module_service
|
||||||
|
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
return # No platform context — let other middleware handle it
|
||||||
|
|
||||||
|
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
return _check_module_enabled
|
||||||
|
|||||||
60
app/core/build_info.py
Normal file
60
app/core/build_info.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# app/core/build_info.py
|
||||||
|
"""
|
||||||
|
Build information utilities.
|
||||||
|
|
||||||
|
Reads commit SHA and deploy timestamp from .build-info file
|
||||||
|
(written by scripts/deploy.sh at deploy time), or falls back
|
||||||
|
to git for local development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUILD_INFO_FILE = Path(__file__).resolve().parent.parent.parent / ".build-info"
|
||||||
|
_cached_info: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_info() -> dict:
|
||||||
|
"""Return build info: commit, deployed_at, environment."""
|
||||||
|
global _cached_info
|
||||||
|
if _cached_info is not None:
|
||||||
|
return _cached_info
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"commit": None,
|
||||||
|
"deployed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try .build-info file first (written by deploy.sh)
|
||||||
|
if _BUILD_INFO_FILE.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(_BUILD_INFO_FILE.read_text())
|
||||||
|
info["commit"] = data.get("commit")
|
||||||
|
info["deployed_at"] = data.get("deployed_at")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read .build-info: {e}")
|
||||||
|
|
||||||
|
# Fall back to git for local development
|
||||||
|
if not info["commit"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short=8", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info["commit"] = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not info["deployed_at"]:
|
||||||
|
info["deployed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
_cached_info = info
|
||||||
|
return info
|
||||||
@@ -6,7 +6,7 @@ This module provides classes and functions for:
|
|||||||
- Configuration management via environment variables
|
- Configuration management via environment variables
|
||||||
- Database settings
|
- Database settings
|
||||||
- JWT and authentication configuration
|
- JWT and authentication configuration
|
||||||
- Platform domain and multi-tenancy settings
|
- Main domain and multi-tenancy settings
|
||||||
- Admin initialization settings
|
- Admin initialization settings
|
||||||
|
|
||||||
Note: Environment detection is handled by app.core.environment module.
|
Note: Environment detection is handled by app.core.environment module.
|
||||||
@@ -94,9 +94,14 @@ class Settings(BaseSettings):
|
|||||||
log_file: str | None = None
|
log_file: str | None = None
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM DOMAIN CONFIGURATION
|
# MAIN DOMAIN CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
platform_domain: str = "wizard.lu"
|
main_domain: str = "wizard.lu"
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard.
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
app_base_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
allow_custom_domains: bool = True
|
allow_custom_domains: bool = True
|
||||||
@@ -218,12 +223,15 @@ class Settings(BaseSettings):
|
|||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GOOGLE WALLET (LOYALTY MODULE)
|
# APPLE WALLET (LOYALTY MODULE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
loyalty_google_issuer_id: str | None = None
|
loyalty_apple_pass_type_id: str | None = None
|
||||||
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
loyalty_apple_team_id: str | None = None
|
||||||
|
loyalty_apple_wwdr_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_cert_path: str | None = None
|
||||||
|
loyalty_apple_signer_key_path: str | None = None
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Singleton settings instance
|
# Singleton settings instance
|
||||||
@@ -342,7 +350,7 @@ def print_environment_info():
|
|||||||
print(f" Database: {settings.database_url}")
|
print(f" Database: {settings.database_url}")
|
||||||
print(f" Debug mode: {settings.debug}")
|
print(f" Debug mode: {settings.debug}")
|
||||||
print(f" API port: {settings.api_port}")
|
print(f" API port: {settings.api_port}")
|
||||||
print(f" Platform: {settings.platform_domain}")
|
print(f" Platform: {settings.main_domain}")
|
||||||
print(f" Secure cookies: {should_use_secure_cookies()}")
|
print(f" Secure cookies: {should_use_secure_cookies()}")
|
||||||
print("=" * 70 + "\n")
|
print("=" * 70 + "\n")
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||||
from sqlalchemy.pool import QueuePool
|
from sqlalchemy.pool import QueuePool
|
||||||
|
|
||||||
from .config import settings, validate_database_url
|
from .config import settings, validate_database_url
|
||||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Soft-delete automatic query filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Any model that inherits SoftDeleteMixin will automatically have
|
||||||
|
# `WHERE deleted_at IS NULL` appended to SELECT queries.
|
||||||
|
# Bypass with: db.execute(stmt, execution_options={"include_deleted": True})
|
||||||
|
# or db.query(Model).execution_options(include_deleted=True).all()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register_soft_delete_filter(session_factory):
|
||||||
|
"""Register the soft-delete query filter on a session factory.
|
||||||
|
|
||||||
|
Call this for any sessionmaker that should auto-exclude soft-deleted records.
|
||||||
|
Used for both the production SessionLocal and test session factories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@event.listens_for(session_factory, "do_orm_execute")
|
||||||
|
def _soft_delete_filter(orm_execute_state):
|
||||||
|
if (
|
||||||
|
orm_execute_state.is_select
|
||||||
|
and not orm_execute_state.execution_options.get("include_deleted", False)
|
||||||
|
):
|
||||||
|
from models.database.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
orm_execute_state.statement = orm_execute_state.statement.options(
|
||||||
|
with_loader_criteria(
|
||||||
|
SoftDeleteMixin,
|
||||||
|
lambda cls: cls.deleted_at.is_(None),
|
||||||
|
include_aliases=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _soft_delete_filter
|
||||||
|
|
||||||
|
|
||||||
|
# Register on the production session factory
|
||||||
|
register_soft_delete_filter(SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Database session dependency for FastAPI routes.
|
Database session dependency for FastAPI routes.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ async def lifespan(app: FastAPI):
|
|||||||
grafana_url=settings.grafana_url,
|
grafana_url=settings.grafana_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate wallet configurations
|
||||||
|
_validate_wallet_config()
|
||||||
|
|
||||||
logger.info("[OK] Application startup completed")
|
logger.info("[OK] Application startup completed")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
@@ -53,6 +56,72 @@ async def lifespan(app: FastAPI):
|
|||||||
shutdown_observability()
|
shutdown_observability()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_wallet_config():
|
||||||
|
"""Validate Google/Apple Wallet configuration at startup."""
|
||||||
|
try:
|
||||||
|
from app.modules.loyalty.services.google_wallet_service import (
|
||||||
|
google_wallet_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = google_wallet_service.validate_config()
|
||||||
|
if result["configured"]:
|
||||||
|
if result["credentials_valid"]:
|
||||||
|
logger.info(
|
||||||
|
"[OK] Google Wallet configured (issuer: %s, email: %s)",
|
||||||
|
result["issuer_id"],
|
||||||
|
result.get("service_account_email", "unknown"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for err in result["errors"]:
|
||||||
|
logger.error("[FAIL] Google Wallet config error: %s", err)
|
||||||
|
else:
|
||||||
|
logger.info("[--] Google Wallet not configured (optional)")
|
||||||
|
|
||||||
|
# Apple Wallet config check
|
||||||
|
if settings.loyalty_apple_pass_type_id:
|
||||||
|
import os
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for field in [
|
||||||
|
"loyalty_apple_team_id",
|
||||||
|
"loyalty_apple_wwdr_cert_path",
|
||||||
|
"loyalty_apple_signer_cert_path",
|
||||||
|
"loyalty_apple_signer_key_path",
|
||||||
|
]:
|
||||||
|
val = getattr(settings, field, None)
|
||||||
|
if not val:
|
||||||
|
missing.append(field)
|
||||||
|
elif field.endswith("_path") and not os.path.isfile(val):
|
||||||
|
logger.error(
|
||||||
|
"[FAIL] Apple Wallet file not found: %s = %s",
|
||||||
|
field,
|
||||||
|
val,
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
logger.error(
|
||||||
|
"[FAIL] Apple Wallet missing config: %s",
|
||||||
|
", ".join(missing),
|
||||||
|
)
|
||||||
|
elif not any(
|
||||||
|
not os.path.isfile(getattr(settings, f, "") or "")
|
||||||
|
for f in [
|
||||||
|
"loyalty_apple_wwdr_cert_path",
|
||||||
|
"loyalty_apple_signer_cert_path",
|
||||||
|
"loyalty_apple_signer_key_path",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"[OK] Apple Wallet configured (pass type: %s)",
|
||||||
|
settings.loyalty_apple_pass_type_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("[--] Apple Wallet not configured (optional)")
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Wallet config validation skipped: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
# === NEW HELPER FUNCTION ===
|
# === NEW HELPER FUNCTION ===
|
||||||
def check_database_ready():
|
def check_database_ready():
|
||||||
"""Check if database is ready (migrations have been run)."""
|
"""Check if database is ready (migrations have been run)."""
|
||||||
|
|||||||
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# app/core/preview_token.py
|
||||||
|
"""
|
||||||
|
Signed preview tokens for POC site previews.
|
||||||
|
|
||||||
|
Generates time-limited JWT tokens that allow viewing storefront pages
|
||||||
|
for stores without active subscriptions (POC sites). The token is
|
||||||
|
validated by StorefrontAccessMiddleware to bypass the subscription gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PREVIEW_TOKEN_HOURS = 24
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
|
||||||
|
"""Create a signed preview token for a POC site.
|
||||||
|
|
||||||
|
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
|
||||||
|
to a specific store_id. Shareable with clients for preview access.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"sub": f"preview:{store_id}",
|
||||||
|
"store_id": store_id,
|
||||||
|
"store_code": store_code,
|
||||||
|
"site_id": site_id,
|
||||||
|
"preview": True,
|
||||||
|
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_preview_token(token: str, store_id: int) -> bool:
|
||||||
|
"""Verify a preview token is valid and matches the store.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- Token signature is valid
|
||||||
|
- Token has not expired
|
||||||
|
- Token has preview=True claim
|
||||||
|
- Token store_id matches the requested store
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
|
||||||
|
return payload.get("preview") is True and payload.get("store_id") == store_id
|
||||||
|
except JWTError:
|
||||||
|
return False
|
||||||
143
app/core/soft_delete.py
Normal file
143
app/core/soft_delete.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# app/core/soft_delete.py
|
||||||
|
"""
|
||||||
|
Soft-delete utility functions.
|
||||||
|
|
||||||
|
Provides helpers for soft-deleting, restoring, and cascade soft-deleting
|
||||||
|
records that use the SoftDeleteMixin.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.core.soft_delete import soft_delete, restore, soft_delete_cascade
|
||||||
|
|
||||||
|
# Simple soft delete
|
||||||
|
soft_delete(db, user, deleted_by_id=admin.id)
|
||||||
|
|
||||||
|
# Cascade soft delete (merchant + all stores + their children)
|
||||||
|
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
|
||||||
|
("stores", [("products", []), ("customers", []), ("orders", []), ("store_users", [])]),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Restore a soft-deleted record
|
||||||
|
from app.modules.tenancy.models import User
|
||||||
|
restore(db, User, entity_id=42, restored_by_id=admin.id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete(db: Session, entity, deleted_by_id: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Mark an entity as soft-deleted.
|
||||||
|
|
||||||
|
Sets deleted_at to now and deleted_by_id to the actor.
|
||||||
|
Does NOT call db.commit() — caller is responsible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||||
|
deleted_by_id: ID of the user performing the deletion.
|
||||||
|
"""
|
||||||
|
entity.deleted_at = datetime.now(UTC)
|
||||||
|
entity.deleted_by_id = deleted_by_id
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Soft-deleted {entity.__class__.__name__} id={entity.id} "
|
||||||
|
f"by user_id={deleted_by_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def restore(
|
||||||
|
db: Session,
|
||||||
|
model_class,
|
||||||
|
entity_id: int,
|
||||||
|
restored_by_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Restore a soft-deleted entity.
|
||||||
|
|
||||||
|
Queries with include_deleted=True to find the record, then clears
|
||||||
|
deleted_at and deleted_by_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
model_class: SQLAlchemy model class.
|
||||||
|
entity_id: ID of the entity to restore.
|
||||||
|
restored_by_id: ID of the user performing the restore (for logging).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The restored entity.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If entity not found.
|
||||||
|
"""
|
||||||
|
entity = db.execute(
|
||||||
|
select(model_class).filter(model_class.id == entity_id),
|
||||||
|
execution_options={"include_deleted": True},
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if entity is None:
|
||||||
|
raise ValueError(f"{model_class.__name__} with id={entity_id} not found")
|
||||||
|
|
||||||
|
if entity.deleted_at is None:
|
||||||
|
raise ValueError(f"{model_class.__name__} with id={entity_id} is not deleted")
|
||||||
|
|
||||||
|
entity.deleted_at = None
|
||||||
|
entity.deleted_by_id = None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Restored {model_class.__name__} id={entity_id} "
|
||||||
|
f"by user_id={restored_by_id}"
|
||||||
|
)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_cascade(
|
||||||
|
db: Session,
|
||||||
|
entity,
|
||||||
|
deleted_by_id: int | None = None,
|
||||||
|
cascade_rels: list[tuple[str, list]] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Soft-delete an entity and recursively soft-delete its children.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
entity: SQLAlchemy model instance with SoftDeleteMixin.
|
||||||
|
deleted_by_id: ID of the user performing the deletion.
|
||||||
|
cascade_rels: List of (relationship_name, child_cascade_rels) tuples.
|
||||||
|
Example: [("stores", [("products", []), ("customers", [])])]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total number of records soft-deleted (including the root entity).
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# Soft-delete the entity itself
|
||||||
|
soft_delete(db, entity, deleted_by_id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Recursively soft-delete children
|
||||||
|
if cascade_rels:
|
||||||
|
for rel_name, child_cascade in cascade_rels:
|
||||||
|
children = getattr(entity, rel_name, None)
|
||||||
|
if children is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle both collections and single items (uselist=False)
|
||||||
|
if not isinstance(children, list):
|
||||||
|
children = [children]
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
if hasattr(child, "deleted_at") and child.deleted_at is None:
|
||||||
|
count += soft_delete_cascade(
|
||||||
|
db, child, deleted_by_id, child_cascade
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -85,8 +85,9 @@ class ErrorPageRenderer:
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with rendered error page
|
HTMLResponse with rendered error page
|
||||||
"""
|
"""
|
||||||
# Get frontend type
|
# Get frontend type — default to PLATFORM in error rendering context
|
||||||
frontend_type = get_frontend_type(request)
|
# (errors can occur before FrontendTypeMiddleware runs)
|
||||||
|
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||||
|
|
||||||
# Prepare template data
|
# Prepare template data
|
||||||
template_data = ErrorPageRenderer._prepare_template_data(
|
template_data = ErrorPageRenderer._prepare_template_data(
|
||||||
@@ -291,7 +292,7 @@ class ErrorPageRenderer:
|
|||||||
# TODO: Implement actual admin check based on JWT/session
|
# TODO: Implement actual admin check based on JWT/session
|
||||||
# For now, check if we're in admin frontend
|
# For now, check if we're in admin frontend
|
||||||
frontend_type = get_frontend_type(request)
|
frontend_type = get_frontend_type(request)
|
||||||
return frontend_type == FrontendType.ADMIN
|
return frontend_type is not None and frontend_type == FrontendType.ADMIN
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _render_basic_html_fallback(
|
def _render_basic_html_fallback(
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
Uses FrontendType detection to determine admin vs store vs storefront login.
|
Uses FrontendType detection to determine admin vs store vs storefront login.
|
||||||
Properly handles multi-access routing (domain, subdomain, path-based).
|
Properly handles multi-access routing (domain, subdomain, path-based).
|
||||||
"""
|
"""
|
||||||
frontend_type = get_frontend_type(request)
|
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||||
|
|
||||||
if frontend_type == FrontendType.ADMIN:
|
if frontend_type == FrontendType.ADMIN:
|
||||||
logger.debug("Redirecting to /admin/login")
|
logger.debug("Redirecting to /admin/login")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "ANALYTICS_"}
|
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"analytics": {
|
|
||||||
"page_title": "Analysen",
|
|
||||||
"dashboard_title": "Analyse-Dashboard",
|
|
||||||
"dashboard_subtitle": "Kuckt Är Buttek Leeschtungsmetriken an Abléck",
|
|
||||||
"period_7d": "Lescht 7 Deeg",
|
|
||||||
"period_30d": "Lescht 30 Deeg",
|
|
||||||
"period_90d": "Lescht 90 Deeg",
|
|
||||||
"period_1y": "Lescht Joer",
|
|
||||||
"imports_count": "Importer",
|
|
||||||
"products_added": "Produkter bäigesat",
|
|
||||||
"inventory_locations": "Lagerplazen",
|
|
||||||
"data_since": "Donnéeë vun",
|
|
||||||
"loading": "Analysen ginn gelueden...",
|
|
||||||
"error_loading": "Analysedonnéeën konnten net geluede ginn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class MenuItemDefinition:
|
|||||||
requires_permission: str | None = None
|
requires_permission: str | None = None
|
||||||
badge_source: str | None = None
|
badge_source: str | None = None
|
||||||
is_super_admin_only: bool = False
|
is_super_admin_only: bool = False
|
||||||
|
header_template: str | None = None # Optional partial for custom header rendering
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -497,7 +498,7 @@ class ModuleDefinition:
|
|||||||
#
|
#
|
||||||
# Example:
|
# Example:
|
||||||
# def _get_onboarding_provider():
|
# def _get_onboarding_provider():
|
||||||
# from app.modules.marketplace.services.marketplace_onboarding import (
|
# from app.modules.marketplace.services.marketplace_onboarding_service import (
|
||||||
# marketplace_onboarding_provider,
|
# marketplace_onboarding_provider,
|
||||||
# )
|
# )
|
||||||
# return marketplace_onboarding_provider
|
# return marketplace_onboarding_provider
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "BILLING_"}
|
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -103,9 +103,12 @@ class RequireFeature:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Check if store's merchant has access to any of the required features."""
|
"""Check if store's merchant has access to any of the required features."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in self.feature_codes:
|
for feature_code in self.feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# None of the features are available
|
# None of the features are available
|
||||||
@@ -136,7 +139,8 @@ class RequireWithinLimit:
|
|||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
|
||||||
allowed, message = feature_service.check_resource_limit(
|
allowed, message = feature_service.check_resource_limit(
|
||||||
db, self.feature_code, store_id=store_id
|
db, self.feature_code, store_id=store_id,
|
||||||
|
platform_id=current_user.token_platform_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in feature_codes:
|
for feature_code in feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||||
@@ -195,9 +202,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
|||||||
)
|
)
|
||||||
|
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
|
platform_id = current_user.token_platform_id
|
||||||
|
|
||||||
for feature_code in feature_codes:
|
for feature_code in feature_codes:
|
||||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
if feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=platform_id
|
||||||
|
):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
||||||
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
||||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
|
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
|
||||||
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
||||||
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
||||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
||||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
|||||||
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
||||||
)
|
)
|
||||||
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
||||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
|
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
|
||||||
|
|
||||||
# --- tier_feature_limits ---
|
# --- tier_feature_limits ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def get_subscription_status(
|
|||||||
):
|
):
|
||||||
"""Get current subscription status."""
|
"""Get current subscription status."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def get_available_tiers(
|
|||||||
):
|
):
|
||||||
"""Get available subscription tiers for upgrade/downgrade."""
|
"""Get available subscription tiers for upgrade/downgrade."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
||||||
current_tier_id = subscription.tier_id
|
current_tier_id = subscription.tier_id
|
||||||
@@ -105,7 +105,7 @@ def get_invoices(
|
|||||||
):
|
):
|
||||||
"""Get invoice history."""
|
"""Get invoice history."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
|||||||
store = billing_service.get_store(db, store_id)
|
store = billing_service.get_store(db, store_id)
|
||||||
|
|
||||||
# Build URLs
|
# Build URLs
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
||||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ def create_checkout_session(
|
|||||||
):
|
):
|
||||||
"""Create a Stripe checkout session for subscription."""
|
"""Create a Stripe checkout session for subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
|
|
||||||
base_url = f"https://{settings.platform_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
||||||
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||||
|
|
||||||
@@ -84,10 +84,10 @@ def create_portal_session(
|
|||||||
):
|
):
|
||||||
"""Create a Stripe customer portal session."""
|
"""Create a Stripe customer portal session."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
return_url = f"https://{settings.platform_domain}/store/{store_code}/billing"
|
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
|
||||||
|
|
||||||
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def cancel_subscription(
|
|||||||
):
|
):
|
||||||
"""Cancel subscription."""
|
"""Cancel subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.cancel_subscription(
|
result = billing_service.cancel_subscription(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -126,7 +126,7 @@ def reactivate_subscription(
|
|||||||
):
|
):
|
||||||
"""Reactivate a cancelled subscription."""
|
"""Reactivate a cancelled subscription."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
|
|||||||
):
|
):
|
||||||
"""Preview the upcoming invoice."""
|
"""Preview the upcoming invoice."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ def change_tier(
|
|||||||
):
|
):
|
||||||
"""Change subscription tier (upgrade/downgrade)."""
|
"""Change subscription tier (upgrade/downgrade)."""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
result = billing_service.change_tier(
|
result = billing_service.change_tier(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def get_available_features(
|
|||||||
List of feature codes the store has access to
|
List of feature codes the store has access to
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get available feature codes
|
# Get available feature codes
|
||||||
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||||
@@ -134,7 +134,7 @@ def get_features(
|
|||||||
List of features with metadata and availability
|
List of features with metadata and availability
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get all declarations and available codes
|
# Get all declarations and available codes
|
||||||
all_declarations = feature_aggregator.get_all_declarations()
|
all_declarations = feature_aggregator.get_all_declarations()
|
||||||
@@ -197,7 +197,7 @@ def get_features_grouped(
|
|||||||
Useful for rendering feature comparison tables or settings pages.
|
Useful for rendering feature comparison tables or settings pages.
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get declarations grouped by category and available codes
|
# Get declarations grouped by category and available codes
|
||||||
by_category = feature_aggregator.get_declarations_by_category()
|
by_category = feature_aggregator.get_declarations_by_category()
|
||||||
@@ -246,7 +246,9 @@ def check_feature(
|
|||||||
has_feature and feature_code
|
has_feature and feature_code
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
has = feature_service.has_feature_for_store(db, store_id, feature_code)
|
has = feature_service.has_feature_for_store(
|
||||||
|
db, store_id, feature_code, platform_id=current_user.token_platform_id
|
||||||
|
)
|
||||||
|
|
||||||
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
||||||
|
|
||||||
@@ -270,7 +272,7 @@ def get_feature_detail(
|
|||||||
Feature details with upgrade info if locked
|
Feature details with upgrade info if locked
|
||||||
"""
|
"""
|
||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
# Get feature declaration
|
# Get feature declaration
|
||||||
decl = feature_aggregator.get_declaration(feature_code)
|
decl = feature_aggregator.get_declaration(feature_code)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class AdminSubscriptionService:
|
|||||||
try:
|
try:
|
||||||
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
platform = platform_service.get_platform_by_id(db, tier.platform_id)
|
||||||
platform_name = platform.name
|
platform_name = platform.name
|
||||||
except Exception:
|
except Exception: # noqa: EXC-003
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Product ---
|
# --- Product ---
|
||||||
|
|||||||
@@ -108,10 +108,17 @@ class FeatureService:
|
|||||||
# Store -> Merchant Resolution
|
# Store -> Merchant Resolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
|
def _get_merchant_for_store(
|
||||||
|
self, db: Session, store_id: int, platform_id: int | None = None
|
||||||
|
) -> tuple[int | None, int | None]:
|
||||||
"""
|
"""
|
||||||
Resolve store_id to (merchant_id, platform_id).
|
Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_id: Store ID
|
||||||
|
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (merchant_id, platform_id), either may be None
|
Tuple of (merchant_id, platform_id), either may be None
|
||||||
"""
|
"""
|
||||||
@@ -123,7 +130,8 @@ class FeatureService:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
merchant_id = store.merchant_id
|
merchant_id = store.merchant_id
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
if platform_id is None:
|
||||||
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
|
|
||||||
return merchant_id, platform_id
|
return merchant_id, platform_id
|
||||||
|
|
||||||
@@ -204,28 +212,29 @@ class FeatureService:
|
|||||||
return subscription.tier.has_feature(feature_code)
|
return subscription.tier.has_feature(feature_code)
|
||||||
|
|
||||||
def has_feature_for_store(
|
def has_feature_for_store(
|
||||||
self, db: Session, store_id: int, feature_code: str
|
self, db: Session, store_id: int, feature_code: str,
|
||||||
|
platform_id: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Convenience method that resolves the store -> merchant -> platform
|
Convenience method that resolves the store -> merchant -> platform
|
||||||
hierarchy and checks whether the merchant has access to a feature.
|
hierarchy and checks whether the merchant has access to a feature.
|
||||||
|
|
||||||
Looks up the store's merchant_id and platform_id, then delegates
|
|
||||||
to has_feature().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session.
|
db: Database session.
|
||||||
store_id: The store ID to resolve.
|
store_id: The store ID to resolve.
|
||||||
feature_code: The feature code to check.
|
feature_code: The feature code to check.
|
||||||
|
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the resolved merchant has access to the feature,
|
True if the resolved merchant has access to the feature,
|
||||||
False if the store/merchant cannot be resolved or lacks access.
|
False if the store/merchant cannot be resolved or lacks access.
|
||||||
"""
|
"""
|
||||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
merchant_id, resolved_platform_id = self._get_merchant_for_store(
|
||||||
if merchant_id is None or platform_id is None:
|
db, store_id, platform_id=platform_id
|
||||||
|
)
|
||||||
|
if merchant_id is None or resolved_platform_id is None:
|
||||||
return False
|
return False
|
||||||
return self.has_feature(db, merchant_id, platform_id, feature_code)
|
return self.has_feature(db, merchant_id, resolved_platform_id, feature_code)
|
||||||
|
|
||||||
def get_merchant_feature_codes(
|
def get_merchant_feature_codes(
|
||||||
self, db: Session, merchant_id: int, platform_id: int
|
self, db: Session, merchant_id: int, platform_id: int
|
||||||
@@ -317,7 +326,7 @@ class FeatureService:
|
|||||||
feature_code: Feature code (e.g., "products_limit")
|
feature_code: Feature code (e.g., "products_limit")
|
||||||
store_id: Store ID (if checking per-store)
|
store_id: Store ID (if checking per-store)
|
||||||
merchant_id: Merchant ID (if already known)
|
merchant_id: Merchant ID (if already known)
|
||||||
platform_id: Platform ID (if already known)
|
platform_id: Platform ID (if already known, e.g. from JWT)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(allowed, error_message) tuple
|
(allowed, error_message) tuple
|
||||||
@@ -326,7 +335,9 @@ class FeatureService:
|
|||||||
|
|
||||||
# Resolve store -> merchant if needed
|
# Resolve store -> merchant if needed
|
||||||
if merchant_id is None and store_id is not None:
|
if merchant_id is None and store_id is not None:
|
||||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
merchant_id, platform_id = self._get_merchant_for_store(
|
||||||
|
db, store_id, platform_id=platform_id
|
||||||
|
)
|
||||||
|
|
||||||
if merchant_id is None or platform_id is None:
|
if merchant_id is None or platform_id is None:
|
||||||
return False, "No subscription found"
|
return False, "No subscription found"
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ class SignupService:
|
|||||||
|
|
||||||
# Build login URL
|
# Build login URL
|
||||||
login_url = (
|
login_url = (
|
||||||
f"https://{settings.platform_domain}"
|
f"{settings.app_base_url.rstrip('/')}"
|
||||||
f"/store/{store.store_code}/dashboard"
|
f"/store/{store.store_code}/dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class StorePlatformSync:
|
|||||||
Upsert StorePlatform for every store belonging to a merchant.
|
Upsert StorePlatform for every store belonging to a merchant.
|
||||||
|
|
||||||
- Existing entry → update is_active (and tier_id if provided)
|
- Existing entry → update is_active (and tier_id if provided)
|
||||||
- Missing + is_active=True → create (set is_primary if store has none)
|
- Missing + is_active=True → create
|
||||||
- Missing + is_active=False → no-op
|
- Missing + is_active=False → no-op
|
||||||
"""
|
"""
|
||||||
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ class StripeService:
|
|||||||
trial_days: int | None = None,
|
trial_days: int | None = None,
|
||||||
quantity: int = 1,
|
quantity: int = 1,
|
||||||
metadata: dict | None = None,
|
metadata: dict | None = None,
|
||||||
|
platform_id: int | None = None,
|
||||||
) -> stripe.checkout.Session:
|
) -> stripe.checkout.Session:
|
||||||
"""
|
"""
|
||||||
Create a Stripe Checkout session for subscription signup.
|
Create a Stripe Checkout session for subscription signup.
|
||||||
@@ -324,6 +325,7 @@ class StripeService:
|
|||||||
trial_days: Optional trial period
|
trial_days: Optional trial period
|
||||||
quantity: Number of items (default 1)
|
quantity: Number of items (default 1)
|
||||||
metadata: Additional metadata to store
|
metadata: Additional metadata to store
|
||||||
|
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Stripe Checkout Session object
|
Stripe Checkout Session object
|
||||||
@@ -334,7 +336,8 @@ class StripeService:
|
|||||||
from app.modules.tenancy.services.platform_service import platform_service
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
from app.modules.tenancy.services.team_service import team_service
|
from app.modules.tenancy.services.team_service import team_service
|
||||||
|
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
|
if platform_id is None:
|
||||||
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||||
subscription = None
|
subscription = None
|
||||||
if store.merchant_id and platform_id:
|
if store.merchant_id and platform_id:
|
||||||
subscription = (
|
subscription = (
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ class SubscriptionService:
|
|||||||
# Store Resolution
|
# Store Resolution
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
|
def resolve_store_to_merchant(
|
||||||
|
self, db: Session, store_id: int, platform_id: int | None = None
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""Resolve store_id to (merchant_id, platform_id).
|
"""Resolve store_id to (merchant_id, platform_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store_id: Store ID
|
||||||
|
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundException: If store not found or has no platform
|
ResourceNotFoundException: If store not found or has no platform
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +66,8 @@ class SubscriptionService:
|
|||||||
store = store_service.get_store_by_id_optional(db, store_id)
|
store = store_service.get_store_by_id_optional(db, store_id)
|
||||||
if not store or not store.merchant_id:
|
if not store or not store.merchant_id:
|
||||||
raise ResourceNotFoundException("Store", str(store_id))
|
raise ResourceNotFoundException("Store", str(store_id))
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
if platform_id is None:
|
||||||
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
if not platform_id:
|
if not platform_id:
|
||||||
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
||||||
return store.merchant_id, platform_id
|
return store.merchant_id, platform_id
|
||||||
@@ -185,7 +193,7 @@ class SubscriptionService:
|
|||||||
if merchant_id is None:
|
if merchant_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
if platform_id is None:
|
if platform_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -114,8 +114,7 @@ def ft_tier_with_features(db, ft_tier):
|
|||||||
TierFeatureLimit(tier_id=ft_tier.id, feature_code="basic_shop", limit_value=None),
|
TierFeatureLimit(tier_id=ft_tier.id, feature_code="basic_shop", limit_value=None),
|
||||||
TierFeatureLimit(tier_id=ft_tier.id, feature_code="team_members", limit_value=5),
|
TierFeatureLimit(tier_id=ft_tier.id, feature_code="team_members", limit_value=5),
|
||||||
]
|
]
|
||||||
for f in features:
|
db.add_all(features)
|
||||||
db.add(f)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# Refresh so the tier's selectin-loaded feature_limits relationship is up to date
|
# Refresh so the tier's selectin-loaded feature_limits relationship is up to date
|
||||||
db.refresh(ft_tier)
|
db.refresh(ft_tier)
|
||||||
|
|||||||
@@ -83,13 +83,12 @@ def billing_extra_platforms(db):
|
|||||||
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
||||||
platforms = []
|
platforms = []
|
||||||
for i in range(2):
|
for i in range(2):
|
||||||
p = Platform(
|
platforms.append(Platform(
|
||||||
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
||||||
name=f"Extra Platform {i}",
|
name=f"Extra Platform {i}",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
))
|
||||||
db.add(p)
|
db.add_all(platforms)
|
||||||
platforms.append(p)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
for p in platforms:
|
for p in platforms:
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ def fs_tier_with_features(db, fs_tier):
|
|||||||
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_b", limit_value=100),
|
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_b", limit_value=100),
|
||||||
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_c", limit_value=50),
|
TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_c", limit_value=50),
|
||||||
]
|
]
|
||||||
for f in features:
|
db.add_all(features)
|
||||||
db.add(f)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return features
|
return features
|
||||||
|
|
||||||
|
|||||||
@@ -39,52 +39,6 @@ class TestStorePlatformSyncCreate:
|
|||||||
assert sp is not None
|
assert sp is not None
|
||||||
assert sp.is_active is True
|
assert sp.is_active is True
|
||||||
|
|
||||||
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
|
|
||||||
"""First platform synced for a store gets is_primary=True."""
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
sp = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == test_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert sp.is_primary is True
|
|
||||||
|
|
||||||
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
|
|
||||||
"""Second platform synced does not override existing primary."""
|
|
||||||
# First platform becomes primary
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
# Second platform should not be primary
|
|
||||||
self.service.sync_store_platforms_for_merchant(
|
|
||||||
db, test_store.merchant_id, another_platform.id, is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
sp1 = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == test_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
sp2 = (
|
|
||||||
db.query(StorePlatform)
|
|
||||||
.filter(
|
|
||||||
StorePlatform.store_id == test_store.id,
|
|
||||||
StorePlatform.platform_id == another_platform.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert sp1.is_primary is True
|
|
||||||
assert sp2.is_primary is False
|
|
||||||
|
|
||||||
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
||||||
"""Sync passes tier_id to newly created StorePlatform."""
|
"""Sync passes tier_id to newly created StorePlatform."""
|
||||||
self.service.sync_store_platforms_for_merchant(
|
self.service.sync_store_platforms_for_merchant(
|
||||||
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
|
|||||||
store_id=test_store.id,
|
store_id=test_store.id,
|
||||||
platform_id=test_platform.id,
|
platform_id=test_platform.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
)
|
)
|
||||||
db.add(sp)
|
db.add(sp)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
|
|||||||
store_id=test_store.id,
|
store_id=test_store.id,
|
||||||
platform_id=test_platform.id,
|
platform_id=test_platform.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
)
|
)
|
||||||
db.add(sp)
|
db.add(sp)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="cart",
|
id="cart",
|
||||||
label_key="storefront.actions.cart",
|
label_key="cart.storefront.actions.cart",
|
||||||
icon="shopping-cart",
|
icon="shopping-cart",
|
||||||
route="cart",
|
route="cart",
|
||||||
order=20,
|
order=20,
|
||||||
|
header_template="cart/storefront/partials/header-cart.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Warenkörbe der Kunden anzeigen",
|
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||||
"manage": "Warenkörbe verwalten",
|
"manage": "Warenkörbe verwalten",
|
||||||
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Warenkorb"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"product_not_available": "Product not available",
|
"product_not_available": "Product not available",
|
||||||
"error_adding": "Error adding item to cart",
|
"error_adding": "Error adding item to cart",
|
||||||
"error_updating": "Error updating cart"
|
"error_updating": "Error updating cart"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Cart"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Voir les paniers des clients",
|
"view_desc": "Voir les paniers des clients",
|
||||||
"manage": "Gérer les paniers",
|
"manage": "Gérer les paniers",
|
||||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Panier"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Clientekuerf kucken",
|
"view_desc": "Clientekuerf kucken",
|
||||||
"manage": "Kuerf verwalten",
|
"manage": "Kuerf verwalten",
|
||||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Kuerf"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Cart page initializing...');
|
console.log('[STOREFRONT] Cart page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
|
console.log(`[STOREFRONT] Loading cart for session ${this.sessionId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
this.cartCount = this.totalItems;
|
this.cartCount = this.totalItems;
|
||||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load cart:', error);
|
console.error('[STOREFRONT] Failed to load cart:', error);
|
||||||
this.showToast('Failed to load cart', 'error');
|
this.showToast('Failed to load cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to update quantity');
|
throw new Error('Failed to update quantity');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Update quantity error:', error);
|
console.error('[STOREFRONT] Update quantity error:', error);
|
||||||
this.showToast('Failed to update quantity', 'error');
|
this.showToast('Failed to update quantity', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing item:', productId);
|
console.log('[STOREFRONT] Removing item:', productId);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove item');
|
throw new Error('Failed to remove item');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Remove item error:', error);
|
console.error('[STOREFRONT] Remove item error:', error);
|
||||||
this.showToast('Failed to remove item', 'error');
|
this.showToast('Failed to remove item', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{# cart/storefront/partials/header-cart.html #}
|
||||||
|
{# Cart icon with badge for storefront header — provided by cart module #}
|
||||||
|
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||||
|
<span x-show="cartCount > 0"
|
||||||
|
x-text="cartCount"
|
||||||
|
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||||
|
style="background-color: var(--color-accent)">
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="products",
|
id="products",
|
||||||
label_key="storefront.nav.products",
|
label_key="catalog.storefront.nav.products",
|
||||||
icon="shopping-bag",
|
icon="shopping-bag",
|
||||||
route="products",
|
route="products",
|
||||||
order=10,
|
order=10,
|
||||||
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="search",
|
id="search",
|
||||||
label_key="storefront.actions.search",
|
label_key="catalog.storefront.actions.search",
|
||||||
icon="search",
|
icon="search",
|
||||||
route="",
|
route="",
|
||||||
order=10,
|
order=10,
|
||||||
|
header_template="catalog/storefront/partials/header-search.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport von Produkten",
|
"products_import_desc": "Massenimport von Produkten",
|
||||||
"products_export": "Produkte exportieren",
|
"products_export": "Produkte exportieren",
|
||||||
"products_export_desc": "Produktdaten exportieren"
|
"products_export_desc": "Produktdaten exportieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkte"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Suchen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,13 @@
|
|||||||
"menu": {
|
"menu": {
|
||||||
"products_inventory": "Products & Inventory",
|
"products_inventory": "Products & Inventory",
|
||||||
"all_products": "All Products"
|
"all_products": "All Products"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Products"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Search"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Importation en masse de produits",
|
"products_import_desc": "Importation en masse de produits",
|
||||||
"products_export": "Exporter les produits",
|
"products_export": "Exporter les produits",
|
||||||
"products_export_desc": "Exporter les données produits"
|
"products_export_desc": "Exporter les données produits"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produits"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Rechercher"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport vu Produiten",
|
"products_import_desc": "Massenimport vu Produiten",
|
||||||
"products_export": "Produiten exportéieren",
|
"products_export": "Produiten exportéieren",
|
||||||
"products_export_desc": "Produitdaten exportéieren"
|
"products_export_desc": "Produitdaten exportéieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkter"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Sichen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ from sqlalchemy.orm import relationship
|
|||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from app.utils.money import cents_to_euros, euros_to_cents
|
from app.utils.money import cents_to_euros, euros_to_cents
|
||||||
from models.database.base import TimestampMixin
|
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Product(Base, TimestampMixin):
|
class Product(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
"""Store-specific product.
|
"""Store-specific product.
|
||||||
|
|
||||||
Products can be created from marketplace imports or directly by stores.
|
Products can be created from marketplace imports or directly by stores.
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ router = APIRouter()
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Render shop homepage / product catalog.
|
Render product catalog listing.
|
||||||
Shows featured products and categories.
|
Shows featured products and categories.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ class ProductService:
|
|||||||
True if deleted
|
True if deleted
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from app.core.soft_delete import soft_delete
|
||||||
|
|
||||||
product = self.get_product(db, store_id, product_id)
|
product = self.get_product(db, store_id, product_id)
|
||||||
|
|
||||||
db.delete(product)
|
soft_delete(db, product, deleted_by_id=None)
|
||||||
|
|
||||||
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Category page initializing...');
|
console.log('[STOREFRONT] Category page initializing...');
|
||||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
console.log('[STOREFRONT] Category slug:', this.categorySlug);
|
||||||
|
|
||||||
// Convert slug to display name
|
// Convert slug to display name
|
||||||
this.categoryName = this.categorySlug
|
this.categoryName = this.categorySlug
|
||||||
@@ -213,7 +213,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
params.append('sort', this.sortBy);
|
params.append('sort', this.sortBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
|
console.log(`[STOREFRONT] Loading category products from /api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
@@ -223,12 +223,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load category products:', error);
|
console.error('[STOREFRONT] Failed to load category products:', error);
|
||||||
this.showToast('Failed to load products', 'error');
|
this.showToast('Failed to load products', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -243,7 +243,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -262,16 +262,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{# catalog/storefront/partials/header-search.html #}
|
||||||
|
{# Search button for storefront header — provided by catalog module #}
|
||||||
|
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
@@ -256,16 +256,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Product detail page initializing...');
|
console.log('[STOREFRONT] Product detail page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
baseData.init.call(this);
|
baseData.init.call(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[SHOP] Product ID:', this.productId);
|
console.log('[STOREFRONT] Product ID:', this.productId);
|
||||||
console.log('[SHOP] Store ID:', this.storeId);
|
console.log('[STOREFRONT] Store ID:', this.storeId);
|
||||||
console.log('[SHOP] Session ID:', this.sessionId);
|
console.log('[STOREFRONT] Session ID:', this.sessionId);
|
||||||
|
|
||||||
await this.loadProduct();
|
await this.loadProduct();
|
||||||
},
|
},
|
||||||
@@ -275,7 +275,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading product ${this.productId}...`);
|
console.log(`[STOREFRONT] Loading product ${this.productId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -283,7 +283,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.product = await response.json();
|
this.product = await response.json();
|
||||||
console.log('[SHOP] Product loaded:', this.product);
|
console.log('[STOREFRONT] Product loaded:', this.product);
|
||||||
|
|
||||||
// Set default image
|
// Set default image
|
||||||
if (this.product?.marketplace_product?.image_link) {
|
if (this.product?.marketplace_product?.image_link) {
|
||||||
@@ -297,7 +297,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
await this.loadRelatedProducts();
|
await this.loadRelatedProducts();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load product:', error);
|
console.error('[STOREFRONT] Failed to load product:', error);
|
||||||
this.showToast('Failed to load product', 'error');
|
this.showToast('Failed to load product', 'error');
|
||||||
// Redirect back to products after error
|
// Redirect back to products after error
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -320,10 +320,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
.filter(p => p.id !== parseInt(this.productId))
|
.filter(p => p.id !== parseInt(this.productId))
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
|
|
||||||
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
console.log('[STOREFRONT] Loaded related products:', this.relatedProducts.length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load related products:', error);
|
console.error('[STOREFRONT] Failed to load related products:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// Add to cart
|
// Add to cart
|
||||||
async addToCart() {
|
async addToCart() {
|
||||||
if (!this.canAddToCart) {
|
if (!this.canAddToCart) {
|
||||||
console.warn('[SHOP] Cannot add to cart:', {
|
console.warn('[STOREFRONT] Cannot add to cart:', {
|
||||||
canAddToCart: this.canAddToCart,
|
canAddToCart: this.canAddToCart,
|
||||||
isActive: this.product?.is_active,
|
isActive: this.product?.is_active,
|
||||||
inventory: this.product?.available_inventory,
|
inventory: this.product?.available_inventory,
|
||||||
@@ -374,7 +374,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
quantity: this.quantity
|
quantity: this.quantity
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[SHOP] Adding to cart:', {
|
console.log('[STOREFRONT] Adding to cart:', {
|
||||||
url,
|
url,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
productId: this.productId,
|
productId: this.productId,
|
||||||
@@ -390,14 +390,14 @@ document.addEventListener('alpine:init', () => {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[SHOP] Add to cart response:', {
|
console.log('[STOREFRONT] Add to cart response:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok
|
ok: response.ok
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
|
|
||||||
this.cartCount += this.quantity;
|
this.cartCount += this.quantity;
|
||||||
this.showToast(
|
this.showToast(
|
||||||
@@ -409,11 +409,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.quantity = this.product?.min_quantity || 1;
|
this.quantity = this.product?.min_quantity || 1;
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error response:', error);
|
console.error('[STOREFRONT] Add to cart error response:', error);
|
||||||
throw new Error(error.detail || 'Failed to add to cart');
|
throw new Error(error.detail || 'Failed to add to cart');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.addingToCart = false;
|
this.addingToCart = false;
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Products page initializing...');
|
console.log('[STOREFRONT] Products page initializing...');
|
||||||
await this.loadProducts();
|
await this.loadProducts();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
params.append('search', this.filters.search);
|
params.append('search', this.filters.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
|
console.log(`[STOREFRONT] Loading products from /api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
@@ -188,12 +188,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.pagination.total = data.total;
|
this.pagination.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load products:', error);
|
console.error('[STOREFRONT] Failed to load products:', error);
|
||||||
this.showToast('Failed to load products', 'error');
|
this.showToast('Failed to load products', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -227,16 +227,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Search page initializing...');
|
console.log('[STOREFRONT] Search page initializing...');
|
||||||
|
|
||||||
// Check for query parameter in URL
|
// Check for query parameter in URL
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -254,7 +254,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
limit: this.perPage
|
limit: this.perPage
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
|
console.log(`[STOREFRONT] Searching: /api/v1/storefront/products/search?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
||||||
|
|
||||||
@@ -264,12 +264,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Search found ${data.total} results`);
|
console.log(`[STOREFRONT] Search found ${data.total} results`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Search failed:', error);
|
console.error('[STOREFRONT] Search failed:', error);
|
||||||
this.showToast('Search failed. Please try again.', 'error');
|
this.showToast('Search failed. Please try again.', 'error');
|
||||||
this.products = [];
|
this.products = [];
|
||||||
this.total = 0;
|
this.total = 0;
|
||||||
@@ -289,7 +289,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -308,16 +308,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Wishlist page initializing...');
|
console.log('[STOREFRONT] Wishlist page initializing...');
|
||||||
|
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
this.isLoggedIn = await this.checkLoginStatus();
|
this.isLoggedIn = await this.checkLoginStatus();
|
||||||
@@ -168,7 +168,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Loading wishlist...');
|
console.log('[STOREFRONT] Loading wishlist...');
|
||||||
|
|
||||||
const response = await fetch('/api/v1/storefront/wishlist');
|
const response = await fetch('/api/v1/storefront/wishlist');
|
||||||
|
|
||||||
@@ -182,11 +182,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
|
console.log(`[STOREFRONT] Loaded ${data.items?.length || 0} wishlist items`);
|
||||||
|
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load wishlist:', error);
|
console.error('[STOREFRONT] Failed to load wishlist:', error);
|
||||||
this.showToast('Failed to load wishlist', 'error');
|
this.showToast('Failed to load wishlist', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -195,7 +195,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
async removeFromWishlist(item) {
|
async removeFromWishlist(item) {
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing from wishlist:', item);
|
console.log('[STOREFRONT] Removing from wishlist:', item);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -208,13 +208,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove from wishlist');
|
throw new Error('Failed to remove from wishlist');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to remove from wishlist:', error);
|
console.error('[STOREFRONT] Failed to remove from wishlist:', error);
|
||||||
this.showToast('Failed to remove from wishlist', 'error');
|
this.showToast('Failed to remove from wishlist', 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -233,16 +233,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "CMS_"}
|
model_config = {"env_prefix": "CMS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mein Konto",
|
||||||
|
"learn_more": "Mehr erfahren",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"quick_links": "Schnellzugriff",
|
||||||
|
"information": "Informationen",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
"content_pages": "Content Pages",
|
"content_pages": "Content Pages",
|
||||||
"store_themes": "Store Themes",
|
"store_themes": "Store Themes",
|
||||||
"media_library": "Media Library"
|
"media_library": "Media Library"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "My Account",
|
||||||
|
"learn_more": "Learn More",
|
||||||
|
"explore": "Explore",
|
||||||
|
"quick_links": "Quick Links",
|
||||||
|
"information": "Information",
|
||||||
|
"about": "About Us",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mon Compte",
|
||||||
|
"learn_more": "En savoir plus",
|
||||||
|
"explore": "Découvrir",
|
||||||
|
"quick_links": "Liens rapides",
|
||||||
|
"information": "Informations",
|
||||||
|
"about": "À propos",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mäi Kont",
|
||||||
|
"learn_more": "Méi gewuer ginn",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"quick_links": "Schnellzougrëff",
|
||||||
|
"information": "Informatiounen",
|
||||||
|
"about": "Iwwer eis",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add meta_description_translations and drop meta_keywords from content_pages
|
||||||
|
|
||||||
|
Revision ID: cms_003
|
||||||
|
Revises: cms_002
|
||||||
|
Create Date: 2026-04-15
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "cms_003"
|
||||||
|
down_revision = "cms_002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"content_pages",
|
||||||
|
sa.Column(
|
||||||
|
"meta_description_translations",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="Language-keyed meta description dict for multi-language SEO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.drop_column("content_pages", "meta_keywords")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"content_pages",
|
||||||
|
sa.Column("meta_keywords", sa.String(300), nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_column("content_pages", "meta_description_translations")
|
||||||
@@ -135,7 +135,12 @@ class ContentPage(Base):
|
|||||||
|
|
||||||
# SEO
|
# SEO
|
||||||
meta_description = Column(String(300), nullable=True)
|
meta_description = Column(String(300), nullable=True)
|
||||||
meta_keywords = Column(String(300), nullable=True)
|
meta_description_translations = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
comment="Language-keyed meta description dict for multi-language SEO",
|
||||||
|
)
|
||||||
|
|
||||||
# Publishing
|
# Publishing
|
||||||
is_published = Column(Boolean, default=False, nullable=False)
|
is_published = Column(Boolean, default=False, nullable=False)
|
||||||
@@ -230,6 +235,16 @@ class ContentPage(Base):
|
|||||||
)
|
)
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
|
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
|
||||||
|
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
|
||||||
|
if self.meta_description_translations:
|
||||||
|
return (
|
||||||
|
self.meta_description_translations.get(lang)
|
||||||
|
or self.meta_description_translations.get(default_lang)
|
||||||
|
or self.meta_description or ""
|
||||||
|
)
|
||||||
|
return self.meta_description or ""
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for API responses."""
|
"""Convert to dictionary for API responses."""
|
||||||
return {
|
return {
|
||||||
@@ -248,7 +263,7 @@ class ContentPage(Base):
|
|||||||
"template": self.template,
|
"template": self.template,
|
||||||
"sections": self.sections,
|
"sections": self.sections,
|
||||||
"meta_description": self.meta_description,
|
"meta_description": self.meta_description,
|
||||||
"meta_keywords": self.meta_keywords,
|
"meta_description_translations": self.meta_description_translations,
|
||||||
"is_published": self.is_published,
|
"is_published": self.is_published,
|
||||||
"published_at": (
|
"published_at": (
|
||||||
self.published_at.isoformat() if self.published_at else None
|
self.published_at.isoformat() if self.published_at else None
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def create_platform_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=page_data.meta_description_translations,
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -117,7 +117,7 @@ def create_store_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=page_data.meta_description_translations,
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -177,11 +177,13 @@ def update_page(
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
|
title_translations=page_data.title_translations,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
|
content_translations=page_data.content_translations,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=page_data.meta_description_translations,
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def list_store_pages(
|
|||||||
|
|
||||||
Returns store-specific overrides + platform defaults (store overrides take precedence).
|
Returns store-specific overrides + platform defaults (store overrides take precedence).
|
||||||
"""
|
"""
|
||||||
platform_id = content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
platform_id = current_user.token_platform_id or content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
||||||
pages = content_page_service.list_pages_for_store(
|
pages = content_page_service.list_pages_for_store(
|
||||||
db, platform_id=platform_id, store_id=current_user.token_store_id, include_unpublished=include_unpublished
|
db, platform_id=platform_id, store_id=current_user.token_store_id, include_unpublished=include_unpublished
|
||||||
)
|
)
|
||||||
@@ -176,7 +176,7 @@ def get_page(
|
|||||||
|
|
||||||
Returns store override if exists, otherwise platform default.
|
Returns store override if exists, otherwise platform default.
|
||||||
"""
|
"""
|
||||||
platform_id = content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
platform_id = current_user.token_platform_id or content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
||||||
page = content_page_service.get_page_for_store_or_raise(
|
page = content_page_service.get_page_for_store_or_raise(
|
||||||
db,
|
db,
|
||||||
platform_id=platform_id,
|
platform_id=platform_id,
|
||||||
@@ -207,7 +207,7 @@ def create_store_page(
|
|||||||
store_id=current_user.token_store_id,
|
store_id=current_user.token_store_id,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=getattr(page_data, "meta_description_translations", None),
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -241,7 +241,7 @@ def update_store_page(
|
|||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=getattr(page_data, "meta_description_translations", None),
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"""
|
"""
|
||||||
CMS Admin Page Routes (HTML rendering).
|
CMS Admin Page Routes (HTML rendering).
|
||||||
|
|
||||||
Admin pages for managing platform and store content pages.
|
Admin pages for managing platform and store content pages,
|
||||||
|
and store theme customization.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Path, Request
|
||||||
@@ -10,6 +11,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_menu_access
|
from app.api.deps import get_db, require_menu_access
|
||||||
|
from app.modules.core.utils.page_context import get_admin_context
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -86,3 +88,49 @@ async def admin_content_page_edit(
|
|||||||
"page_id": page_id,
|
"page_id": page_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STORE THEMES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/store-themes", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def admin_store_themes_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(
|
||||||
|
require_menu_access("store-themes", FrontendType.ADMIN)
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render store themes selection page.
|
||||||
|
Allows admins to select a store to customize their theme.
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"cms/admin/store-themes.html",
|
||||||
|
get_admin_context(request, db, current_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stores/{store_code}/theme",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def admin_store_theme_page(
|
||||||
|
request: Request,
|
||||||
|
store_code: str = Path(..., description="Store code"),
|
||||||
|
current_user: User = Depends(
|
||||||
|
require_menu_access("store-themes", FrontendType.ADMIN)
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render store theme customization page.
|
||||||
|
Allows admins to customize colors, fonts, layout, and branding.
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"cms/admin/store-theme.html",
|
||||||
|
get_admin_context(request, db, current_user, store_code=store_code),
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from app.modules.cms.services import content_page_service
|
|||||||
from app.modules.core.services.platform_settings_service import (
|
from app.modules.core.services.platform_settings_service import (
|
||||||
platform_settings_service, # MOD-004 - shared platform service
|
platform_settings_service, # MOD-004 - shared platform service
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.models import Store, User
|
from app.modules.core.utils.page_context import get_store_context
|
||||||
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -33,54 +34,6 @@ ROUTE_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HELPER: Build Store Dashboard Context
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def get_store_context(
|
|
||||||
request: Request,
|
|
||||||
db: Session,
|
|
||||||
current_user: User,
|
|
||||||
store_code: str,
|
|
||||||
**extra_context,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Build template context for store dashboard pages.
|
|
||||||
|
|
||||||
Resolves locale/currency using the platform settings service with
|
|
||||||
store override support.
|
|
||||||
"""
|
|
||||||
# Load store from database
|
|
||||||
store = db.query(Store).filter(Store.subdomain == store_code).first()
|
|
||||||
|
|
||||||
# Get platform defaults
|
|
||||||
platform_config = platform_settings_service.get_storefront_config(db)
|
|
||||||
|
|
||||||
# Resolve with store override
|
|
||||||
storefront_locale = platform_config["locale"]
|
|
||||||
storefront_currency = platform_config["currency"]
|
|
||||||
|
|
||||||
if store and store.storefront_locale:
|
|
||||||
storefront_locale = store.storefront_locale
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"request": request,
|
|
||||||
"user": current_user,
|
|
||||||
"store": store,
|
|
||||||
"store_code": store_code,
|
|
||||||
"storefront_locale": storefront_locale,
|
|
||||||
"storefront_currency": storefront_currency,
|
|
||||||
"dashboard_language": store.dashboard_language if store else "en",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add any extra context
|
|
||||||
if extra_context:
|
|
||||||
context.update(extra_context)
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONTENT PAGES MANAGEMENT
|
# CONTENT PAGES MANAGEMENT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -28,6 +28,79 @@ ROUTE_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STOREFRONT HOMEPAGE
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def storefront_homepage(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Storefront homepage handler.
|
||||||
|
|
||||||
|
Looks for a CMS page with slug="home" (store override → store default),
|
||||||
|
and renders the appropriate landing template. Falls back to the default
|
||||||
|
landing template when no CMS homepage exists.
|
||||||
|
"""
|
||||||
|
store = getattr(request.state, "store", None)
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
store_id = store.id if store else None
|
||||||
|
if not platform:
|
||||||
|
raise HTTPException(status_code=400, detail="Platform context required")
|
||||||
|
|
||||||
|
# Try to load a homepage from CMS (store override → store default)
|
||||||
|
page = content_page_service.get_page_for_store(
|
||||||
|
db,
|
||||||
|
platform_id=platform.id,
|
||||||
|
slug="home",
|
||||||
|
store_id=store_id,
|
||||||
|
include_unpublished=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve placeholders for store default pages (title, content, sections)
|
||||||
|
page_content = None
|
||||||
|
page_title = None
|
||||||
|
page_sections = None
|
||||||
|
if page:
|
||||||
|
page_content = page.content
|
||||||
|
page_title = page.title
|
||||||
|
page_sections = page.sections
|
||||||
|
if page.is_store_default and store:
|
||||||
|
page_content = content_page_service.resolve_placeholders(
|
||||||
|
page.content, store
|
||||||
|
)
|
||||||
|
page_title = content_page_service.resolve_placeholders(
|
||||||
|
page.title, store
|
||||||
|
)
|
||||||
|
if page_sections:
|
||||||
|
page_sections = content_page_service.resolve_placeholders_deep(
|
||||||
|
page_sections, store
|
||||||
|
)
|
||||||
|
|
||||||
|
context = get_storefront_context(request, db=db, page=page)
|
||||||
|
if page_content:
|
||||||
|
context["page_content"] = page_content
|
||||||
|
if page_title:
|
||||||
|
context["page_title"] = page_title
|
||||||
|
if page_sections:
|
||||||
|
context["page_sections"] = page_sections
|
||||||
|
|
||||||
|
# Select template based on page.template field (or default)
|
||||||
|
template_map = {
|
||||||
|
"full": "cms/storefront/landing-full.html",
|
||||||
|
"modern": "cms/storefront/landing-modern.html",
|
||||||
|
"minimal": "cms/storefront/landing-minimal.html",
|
||||||
|
}
|
||||||
|
template_name = "cms/storefront/landing-default.html"
|
||||||
|
if page and page.template:
|
||||||
|
template_name = template_map.get(page.template, template_name)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(template_name, context)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DYNAMIC CONTENT PAGES (CMS)
|
# DYNAMIC CONTENT PAGES (CMS)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -103,14 +176,25 @@ async def generic_content_page(
|
|||||||
|
|
||||||
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
||||||
page_content = page.content
|
page_content = page.content
|
||||||
|
page_title = page.title
|
||||||
if page.is_store_default and store:
|
if page.is_store_default and store:
|
||||||
page_content = content_page_service.resolve_placeholders(page.content, store)
|
page_content = content_page_service.resolve_placeholders(page.content, store)
|
||||||
|
page_title = content_page_service.resolve_placeholders(page.title, store)
|
||||||
|
|
||||||
context = get_storefront_context(request, db=db, page=page)
|
context = get_storefront_context(request, db=db, page=page)
|
||||||
|
context["page_title"] = page_title
|
||||||
context["page_content"] = page_content
|
context["page_content"] = page_content
|
||||||
|
|
||||||
|
# Select template based on page.template field
|
||||||
|
template_map = {
|
||||||
|
"full": "cms/storefront/landing-full.html",
|
||||||
|
"modern": "cms/storefront/landing-modern.html",
|
||||||
|
"minimal": "cms/storefront/landing-minimal.html",
|
||||||
|
}
|
||||||
|
template_name = template_map.get(page.template, "cms/storefront/content-page.html")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"cms/storefront/content-page.html",
|
template_name,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
|
|||||||
description="URL-safe identifier (about, faq, contact, etc.)",
|
description="URL-safe identifier (about, faq, contact, etc.)",
|
||||||
)
|
)
|
||||||
title: str = Field(..., max_length=200, description="Page title")
|
title: str = Field(..., max_length=200, description="Page title")
|
||||||
|
title_translations: dict[str, str] | None = Field(
|
||||||
|
None, description="Title translations keyed by language code"
|
||||||
|
)
|
||||||
content: str = Field(..., description="HTML or Markdown content")
|
content: str = Field(..., description="HTML or Markdown content")
|
||||||
|
content_translations: dict[str, str] | None = Field(
|
||||||
|
None, description="Content translations keyed by language code"
|
||||||
|
)
|
||||||
content_format: str = Field(
|
content_format: str = Field(
|
||||||
default="html", description="Content format: html or markdown"
|
default="html", description="Content format: html or markdown"
|
||||||
)
|
)
|
||||||
template: str = Field(
|
template: str = Field(
|
||||||
default="default",
|
default="default",
|
||||||
max_length=50,
|
max_length=50,
|
||||||
description="Template name (default, minimal, modern)",
|
description="Template name (default, minimal, modern, full)",
|
||||||
)
|
)
|
||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
None, max_length=300, description="SEO meta description"
|
||||||
)
|
)
|
||||||
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
|
meta_description_translations: dict[str, str] | None = Field(
|
||||||
|
None, description="Meta description translations keyed by language code"
|
||||||
|
)
|
||||||
is_published: bool = Field(default=False, description="Publish immediately")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||||
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
|
|||||||
"""Schema for updating a content page (admin)."""
|
"""Schema for updating a content page (admin)."""
|
||||||
|
|
||||||
title: str | None = Field(None, max_length=200)
|
title: str | None = Field(None, max_length=200)
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
template: str | None = Field(None, max_length=50)
|
template: str | None = Field(None, max_length=50)
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
meta_description: str | None = Field(None, max_length=300)
|
||||||
meta_keywords: str | None = Field(None, max_length=300)
|
meta_description_translations: dict[str, str] | None = None
|
||||||
is_published: bool | None = None
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
|
|||||||
store_name: str | None
|
store_name: str | None
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str
|
content: str
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str
|
content_format: str
|
||||||
template: str | None = None
|
template: str | None = None
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
meta_description_translations: dict[str, str] | None = None
|
||||||
is_published: bool
|
is_published: bool
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
display_order: int
|
display_order: int
|
||||||
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
|
|||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
None, max_length=300, description="SEO meta description"
|
||||||
)
|
)
|
||||||
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
|
|
||||||
is_published: bool = Field(default=False, description="Publish immediately")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||||
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
|
|||||||
content: str | None = None
|
content: str | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
meta_description: str | None = Field(None, max_length=300)
|
||||||
meta_keywords: str | None = Field(None, max_length=300)
|
|
||||||
is_published: bool | None = None
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
content_format: str
|
content_format: str
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -47,7 +48,7 @@ class ContentPageService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_platform_id(db: Session, store_id: int) -> int | None:
|
def resolve_platform_id(db: Session, store_id: int) -> int | None:
|
||||||
"""
|
"""
|
||||||
Resolve platform_id from store's primary StorePlatform.
|
Resolve platform_id from store's first active StorePlatform.
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
1. Primary StorePlatform for the store
|
1. Primary StorePlatform for the store
|
||||||
@@ -62,7 +63,7 @@ class ContentPageService:
|
|||||||
"""
|
"""
|
||||||
from app.modules.tenancy.services.platform_service import platform_service
|
from app.modules.tenancy.services.platform_service import platform_service
|
||||||
|
|
||||||
return platform_service.get_primary_platform_id_for_store(db, store_id)
|
return platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_platform_id_or_raise(db: Session, store_id: int) -> int:
|
def resolve_platform_id_or_raise(db: Session, store_id: int) -> int:
|
||||||
@@ -472,7 +473,7 @@ class ContentPageService:
|
|||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
template: str = "default",
|
template: str = "default",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -494,7 +495,7 @@ class ContentPageService:
|
|||||||
content_format: "html" or "markdown"
|
content_format: "html" or "markdown"
|
||||||
template: Template name for landing pages
|
template: Template name for landing pages
|
||||||
meta_description: SEO description
|
meta_description: SEO description
|
||||||
meta_keywords: SEO keywords
|
meta_description_translations: Meta description translations dict
|
||||||
is_published: Publish immediately
|
is_published: Publish immediately
|
||||||
show_in_footer: Show in footer navigation
|
show_in_footer: Show in footer navigation
|
||||||
show_in_header: Show in header navigation
|
show_in_header: Show in header navigation
|
||||||
@@ -515,7 +516,7 @@ class ContentPageService:
|
|||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
published_at=datetime.now(UTC) if is_published else None,
|
published_at=datetime.now(UTC) if is_published else None,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
@@ -541,11 +542,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -573,16 +576,20 @@ class ContentPageService:
|
|||||||
# Update fields if provided
|
# Update fields if provided
|
||||||
if title is not None:
|
if title is not None:
|
||||||
page.title = title
|
page.title = title
|
||||||
|
if title_translations is not None:
|
||||||
|
page.title_translations = title_translations
|
||||||
if content is not None:
|
if content is not None:
|
||||||
page.content = content
|
page.content = content
|
||||||
|
if content_translations is not None:
|
||||||
|
page.content_translations = content_translations
|
||||||
if content_format is not None:
|
if content_format is not None:
|
||||||
page.content_format = content_format
|
page.content_format = content_format
|
||||||
if template is not None:
|
if template is not None:
|
||||||
page.template = template
|
page.template = template
|
||||||
if meta_description is not None:
|
if meta_description is not None:
|
||||||
page.meta_description = meta_description
|
page.meta_description = meta_description
|
||||||
if meta_keywords is not None:
|
if meta_description_translations is not None:
|
||||||
page.meta_keywords = meta_keywords
|
page.meta_description_translations = meta_description_translations
|
||||||
if is_published is not None:
|
if is_published is not None:
|
||||||
page.is_published = is_published
|
page.is_published = is_published
|
||||||
if is_published and not page.published_at:
|
if is_published and not page.published_at:
|
||||||
@@ -698,7 +705,7 @@ class ContentPageService:
|
|||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -725,7 +732,7 @@ class ContentPageService:
|
|||||||
content=content,
|
content=content,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -760,7 +767,7 @@ class ContentPageService:
|
|||||||
content: str,
|
content: str,
|
||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -791,7 +798,7 @@ class ContentPageService:
|
|||||||
is_platform_page=False,
|
is_platform_page=False,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -913,11 +920,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -935,11 +944,13 @@ class ContentPageService:
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
title_translations=title_translations,
|
||||||
content=content,
|
content=content,
|
||||||
|
content_translations=content_translations,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -991,6 +1002,28 @@ class ContentPageService:
|
|||||||
content = content.replace(placeholder, value)
|
content = content.replace(placeholder, value)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_placeholders_deep(data, store) -> Any:
|
||||||
|
"""
|
||||||
|
Recursively resolve {{store_name}} etc. in a nested data structure
|
||||||
|
(dicts, lists, strings). Used for sections JSON in store default pages.
|
||||||
|
"""
|
||||||
|
if not data or not store:
|
||||||
|
return data
|
||||||
|
if isinstance(data, str):
|
||||||
|
return ContentPageService.resolve_placeholders(data, store)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
k: ContentPageService.resolve_placeholders_deep(v, store)
|
||||||
|
for k, v in data.items()
|
||||||
|
}
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [
|
||||||
|
ContentPageService.resolve_placeholders_deep(item, store)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Homepage Sections Management
|
# Homepage Sections Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class StoreThemeService:
|
|||||||
"""
|
"""
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = store_service.get_store_by_code(db, store_code)
|
store = store_service.get_store_by_code_or_subdomain(db, store_code)
|
||||||
|
|
||||||
if not store:
|
if not store:
|
||||||
self.logger.warning(f"Store not found: {store_code}")
|
self.logger.warning(f"Store not found: {store_code}")
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
|
|||||||
form: {
|
form: {
|
||||||
slug: '',
|
slug: '',
|
||||||
title: '',
|
title: '',
|
||||||
|
title_translations: {},
|
||||||
content: '',
|
content: '',
|
||||||
|
content_translations: {},
|
||||||
content_format: 'html',
|
content_format: 'html',
|
||||||
template: 'default',
|
template: 'default',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
meta_keywords: '',
|
meta_description_translations: {},
|
||||||
is_published: false,
|
is_published: false,
|
||||||
show_in_header: false,
|
show_in_header: false,
|
||||||
show_in_footer: true,
|
show_in_footer: true,
|
||||||
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
|
|||||||
error: null,
|
error: null,
|
||||||
successMessage: null,
|
successMessage: null,
|
||||||
|
|
||||||
|
// Page type: 'content' or 'landing'
|
||||||
|
pageType: 'content',
|
||||||
|
|
||||||
|
// Translation language for title/content
|
||||||
|
titleContentLang: 'fr',
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// HOMEPAGE SECTIONS STATE
|
// HOMEPAGE SECTIONS STATE
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
|
|||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
lb: 'Lëtzebuergesch'
|
lb: 'Lëtzebuergesch'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Template-driven section palette
|
||||||
|
sectionPalette: {
|
||||||
|
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
|
||||||
|
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
|
||||||
|
},
|
||||||
|
|
||||||
sections: {
|
sections: {
|
||||||
hero: {
|
hero: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
|
|||||||
await this.loadPage();
|
await this.loadPage();
|
||||||
contentPageEditLog.groupEnd();
|
contentPageEditLog.groupEnd();
|
||||||
|
|
||||||
// Load sections if this is a homepage
|
// Load sections if this is a landing page
|
||||||
if (this.form.slug === 'home') {
|
if (this.pageType === 'landing') {
|
||||||
await this.loadSections();
|
await this.loadSections();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
|
|||||||
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check if we should show section editor (property, not getter for Alpine compatibility)
|
// Check if we should show section editor
|
||||||
isHomepage: false,
|
isHomepage: false,
|
||||||
|
|
||||||
// Update isHomepage when slug changes
|
// Is a section available for the current template?
|
||||||
|
isSectionAvailable(sectionName) {
|
||||||
|
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
|
||||||
|
return palette.includes(sectionName);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update homepage state
|
||||||
updateIsHomepage() {
|
updateIsHomepage() {
|
||||||
this.isHomepage = this.form.slug === 'home';
|
this.isHomepage = this.form.slug === 'home';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Update template when page type changes
|
||||||
|
updatePageType() {
|
||||||
|
if (this.pageType === 'landing') {
|
||||||
|
this.form.template = 'full';
|
||||||
|
// Load sections if editing and not yet loaded
|
||||||
|
if (this.pageId && !this.sectionsLoaded) {
|
||||||
|
this.loadSections();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.form.template = 'default';
|
||||||
|
}
|
||||||
|
this.updateIsHomepage();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TITLE/CONTENT TRANSLATION HELPERS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
getTranslatedTitle() {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
return this.form.title;
|
||||||
|
}
|
||||||
|
return (this.form.title_translations || {})[this.titleContentLang] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTranslatedTitle(value) {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
this.form.title = value;
|
||||||
|
} else {
|
||||||
|
if (!this.form.title_translations) this.form.title_translations = {};
|
||||||
|
this.form.title_translations[this.titleContentLang] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTranslatedContent() {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
return this.form.content;
|
||||||
|
}
|
||||||
|
return (this.form.content_translations || {})[this.titleContentLang] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTranslatedContent(value) {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
this.form.content = value;
|
||||||
|
} else {
|
||||||
|
if (!this.form.content_translations) this.form.content_translations = {};
|
||||||
|
this.form.content_translations[this.titleContentLang] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTranslatedMetaDescription() {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
return this.form.meta_description;
|
||||||
|
}
|
||||||
|
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTranslatedMetaDescription(value) {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
this.form.meta_description = value;
|
||||||
|
} else {
|
||||||
|
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
|
||||||
|
this.form.meta_description_translations[this.titleContentLang] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Load platforms for dropdown
|
// Load platforms for dropdown
|
||||||
async loadPlatforms() {
|
async loadPlatforms() {
|
||||||
this.loadingPlatforms = true;
|
this.loadingPlatforms = true;
|
||||||
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
|
|||||||
this.form = {
|
this.form = {
|
||||||
slug: page.slug || '',
|
slug: page.slug || '',
|
||||||
title: page.title || '',
|
title: page.title || '',
|
||||||
|
title_translations: page.title_translations || {},
|
||||||
content: page.content || '',
|
content: page.content || '',
|
||||||
|
content_translations: page.content_translations || {},
|
||||||
content_format: page.content_format || 'html',
|
content_format: page.content_format || 'html',
|
||||||
template: page.template || 'default',
|
template: page.template || 'default',
|
||||||
meta_description: page.meta_description || '',
|
meta_description: page.meta_description || '',
|
||||||
meta_keywords: page.meta_keywords || '',
|
meta_description_translations: page.meta_description_translations || {},
|
||||||
is_published: page.is_published || false,
|
is_published: page.is_published || false,
|
||||||
show_in_header: page.show_in_header || false,
|
show_in_header: page.show_in_header || false,
|
||||||
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
||||||
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
|
|||||||
store_id: page.store_id
|
store_id: page.store_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set page type from template
|
||||||
|
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
|
||||||
|
|
||||||
contentPageEditLog.info('Page loaded successfully');
|
contentPageEditLog.info('Page loaded successfully');
|
||||||
|
|
||||||
// Update computed properties after loading
|
// Update computed properties after loading
|
||||||
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// HOMEPAGE SECTIONS METHODS
|
// SECTIONS METHODS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Load sections for homepage
|
// Load sections for landing pages
|
||||||
async loadSections() {
|
async loadSections() {
|
||||||
if (!this.pageId || this.form.slug !== 'home') {
|
if (!this.pageId || this.pageType !== 'landing') {
|
||||||
contentPageEditLog.debug('Skipping section load - not a homepage');
|
contentPageEditLog.debug('Skipping section load - not a landing page');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Loading homepage sections...');
|
contentPageEditLog.info('Loading sections...');
|
||||||
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
||||||
const data = response.data || response;
|
const data = response.data || response;
|
||||||
|
|
||||||
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
||||||
this.defaultLanguage = data.default_language || 'fr';
|
this.defaultLanguage = data.default_language || 'fr';
|
||||||
this.currentLang = this.defaultLanguage;
|
this.currentLang = this.defaultLanguage;
|
||||||
|
this.titleContentLang = this.defaultLanguage;
|
||||||
|
|
||||||
if (data.sections) {
|
if (data.sections) {
|
||||||
this.sections = this.mergeWithDefaults(data.sections);
|
this.sections = this.mergeWithDefaults(data.sections);
|
||||||
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
|
|||||||
mergeWithDefaults(loadedSections) {
|
mergeWithDefaults(loadedSections) {
|
||||||
const defaults = this.getDefaultSectionStructure();
|
const defaults = this.getDefaultSectionStructure();
|
||||||
|
|
||||||
// Deep merge each section
|
// Deep merge each section that exists in defaults
|
||||||
for (const key of ['hero', 'features', 'pricing', 'cta']) {
|
for (const key of Object.keys(defaults)) {
|
||||||
if (loadedSections[key]) {
|
if (loadedSections[key]) {
|
||||||
defaults[key] = { ...defaults[key], ...loadedSections[key] };
|
defaults[key] = { ...defaults[key], ...loadedSections[key] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Also preserve any extra sections from loaded data
|
||||||
|
for (const key of Object.keys(loadedSections)) {
|
||||||
|
if (!defaults[key]) {
|
||||||
|
defaults[key] = loadedSections[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return defaults;
|
return defaults;
|
||||||
},
|
},
|
||||||
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
|
|||||||
|
|
||||||
// Save sections
|
// Save sections
|
||||||
async saveSections() {
|
async saveSections() {
|
||||||
if (!this.pageId || !this.isHomepage) return;
|
if (!this.pageId || this.pageType !== 'landing') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Saving sections...');
|
contentPageEditLog.info('Saving sections...');
|
||||||
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
slug: this.form.slug,
|
slug: this.form.slug,
|
||||||
title: this.form.title,
|
title: this.form.title,
|
||||||
|
title_translations: this.form.title_translations,
|
||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
|
content_translations: this.form.content_translations,
|
||||||
content_format: this.form.content_format,
|
content_format: this.form.content_format,
|
||||||
template: this.form.template,
|
template: this.form.template,
|
||||||
meta_description: this.form.meta_description,
|
meta_description: this.form.meta_description,
|
||||||
meta_keywords: this.form.meta_keywords,
|
meta_description_translations: this.form.meta_description_translations,
|
||||||
is_published: this.form.is_published,
|
is_published: this.form.is_published,
|
||||||
show_in_header: this.form.show_in_header,
|
show_in_header: this.form.show_in_header,
|
||||||
show_in_footer: this.form.show_in_footer,
|
show_in_footer: this.form.show_in_footer,
|
||||||
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
|
|||||||
// Update existing page
|
// Update existing page
|
||||||
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
||||||
|
|
||||||
// Also save sections if this is a homepage
|
// Also save sections if this is a landing page
|
||||||
if (this.isHomepage && this.sectionsLoaded) {
|
if (this.pageType === 'landing' && this.sectionsLoaded) {
|
||||||
await this.saveSections();
|
await this.saveSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,23 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Page Title -->
|
<!-- Page Type -->
|
||||||
<div class="md:col-span-2">
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Page Title <span class="text-red-500">*</span>
|
Page Type
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
x-model="pageType"
|
||||||
x-model="form.title"
|
@change="updatePageType()"
|
||||||
required
|
|
||||||
maxlength="200"
|
|
||||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||||
placeholder="About Us"
|
|
||||||
>
|
>
|
||||||
|
<option value="content">Content Page</option>
|
||||||
|
<option value="landing">Landing Page</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
|
||||||
|
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slug -->
|
<!-- Slug -->
|
||||||
@@ -133,10 +137,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Title with Language Tabs -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Page Title
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Language Tabs for Title/Content -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex -mb-px space-x-4">
|
||||||
|
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="titleContentLang = lang"
|
||||||
|
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||||
|
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
|
||||||
|
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Title <span class="text-red-500">*</span>
|
||||||
|
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="getTranslatedTitle()"
|
||||||
|
@input="setTranslatedTitle($event.target.value)"
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||||
|
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content (only for Content Page type) -->
|
||||||
|
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Page Content
|
Page Content
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Content Format -->
|
<!-- Content Format -->
|
||||||
@@ -219,9 +267,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||||
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
|
<!-- SECTIONS EDITOR (for Landing Page type) -->
|
||||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||||
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Homepage Sections
|
Homepage Sections
|
||||||
@@ -258,7 +306,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- HERO SECTION -->
|
<!-- HERO SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||||
@@ -341,7 +389,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- FEATURES SECTION -->
|
<!-- FEATURES SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||||
@@ -410,7 +458,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- PRICING SECTION -->
|
<!-- PRICING SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||||
@@ -448,7 +496,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- CTA SECTION -->
|
<!-- CTA SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||||
@@ -525,6 +573,7 @@
|
|||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
SEO & Metadata
|
SEO & Metadata
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -534,30 +583,17 @@
|
|||||||
Meta Description
|
Meta Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
x-model="form.meta_description"
|
:value="getTranslatedMetaDescription()"
|
||||||
|
@input="setTranslatedMetaDescription($event.target.value)"
|
||||||
rows="2"
|
rows="2"
|
||||||
maxlength="300"
|
maxlength="300"
|
||||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||||
placeholder="A brief description for search engines"
|
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
|
150-160 characters recommended for search engines
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meta Keywords -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Meta Keywords
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="form.meta_keywords"
|
|
||||||
maxlength="300"
|
|
||||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
|
||||||
placeholder="keyword1, keyword2, keyword3"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -459,5 +459,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-theme.js') }}"></script>
|
<script defer src="{{ url_for('cms_static', path='admin/js/store-theme.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -125,5 +125,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
||||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-themes.js') }}"></script>
|
<script defer src="{{ url_for('cms_static', path='admin/js/store-themes.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
{% from 'cms/platform/sections/_products.html' import render_products %}
|
{% from 'cms/platform/sections/_products.html' import render_products %}
|
||||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||||
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
|
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
|
||||||
|
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||||
|
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||||
|
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@@ -51,6 +54,21 @@
|
|||||||
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Testimonials Section #}
|
||||||
|
{% if page.sections.testimonials %}
|
||||||
|
{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Gallery Section #}
|
||||||
|
{% if page.sections.gallery %}
|
||||||
|
{{ render_gallery(page.sections.gallery, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Contact Info Section #}
|
||||||
|
{% if page.sections.contact_info %}
|
||||||
|
{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# CTA Section #}
|
{# CTA Section #}
|
||||||
{% if page.sections.cta %}
|
{% if page.sections.cta %}
|
||||||
{{ render_cta(page.sections.cta, lang, default_lang) }}
|
{{ render_cta(page.sections.cta, lang, default_lang) }}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{# Section partial: Contact Information #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- contact_info: dict with enabled, title, email, phone, address, hours, map_embed_url
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_contact_info(contact_info, lang, default_lang) %}
|
||||||
|
{% if contact_info and contact_info.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = contact_info.title.translations.get(lang) or contact_info.title.translations.get(default_lang) or 'Contact' %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||||
|
{% if contact_info.phone %}
|
||||||
|
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 dark:text-purple-300 text-xl">📞</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Phone</h3>
|
||||||
|
<a href="tel:{{ contact_info.phone }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||||
|
{{ contact_info.phone }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if contact_info.email %}
|
||||||
|
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 dark:text-purple-300 text-xl">📧</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h3>
|
||||||
|
<a href="mailto:{{ contact_info.email }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||||
|
{{ contact_info.email }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if contact_info.address %}
|
||||||
|
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 dark:text-purple-300 text-xl">📍</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Address</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">{{ contact_info.address }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if contact_info.hours %}
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-semibold">Hours:</span> {{ contact_info.hours }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{# Section partial: Image Gallery #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- gallery: dict with enabled, title, images (list of {src, alt, caption})
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_gallery(gallery, lang, default_lang) %}
|
||||||
|
{% if gallery and gallery.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = gallery.title.translations.get(lang) or gallery.title.translations.get(default_lang) or '' %}
|
||||||
|
{% if title %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Image grid #}
|
||||||
|
{% if gallery.images %}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{% for image in gallery.images %}
|
||||||
|
<div class="relative group overflow-hidden rounded-lg aspect-square">
|
||||||
|
<img src="{{ image.src }}"
|
||||||
|
alt="{{ image.alt or '' }}"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||||
|
loading="lazy">
|
||||||
|
{% if image.caption %}
|
||||||
|
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<p class="text-sm text-white">{{ image.caption }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{# Section partial: Testimonials #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- testimonials: dict with enabled, title, subtitle, items
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_testimonials(testimonials, lang, default_lang) %}
|
||||||
|
{% if testimonials and testimonials.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = testimonials.title.translations.get(lang) or testimonials.title.translations.get(default_lang) or '' %}
|
||||||
|
{% if title %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Testimonial cards — use .get() to avoid dict.items() method collision with JSON dicts #}
|
||||||
|
{% set testimonial_items = testimonials.get('items', []) if testimonials is mapping else [] %}
|
||||||
|
{% if testimonial_items %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{% for item in testimonial_items %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="flex text-yellow-400">
|
||||||
|
{% for _ in range(5) %}
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set content = item.content %}
|
||||||
|
{% if content is mapping %}
|
||||||
|
{% set content = content.translations.get(lang) or content.translations.get(default_lang) or '' %}
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6 italic">"{{ content }}"</p>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if item.avatar %}
|
||||||
|
<img src="{{ item.avatar }}" alt="" class="w-10 h-10 rounded-full mr-3">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-3">
|
||||||
|
<span class="text-sm font-bold text-purple-600 dark:text-purple-300">
|
||||||
|
{% set author = item.author %}
|
||||||
|
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '?' %}{% endif %}
|
||||||
|
{{ author[0]|upper if author else '?' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
{% set author = item.author %}
|
||||||
|
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '' %}{% endif %}
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ author }}</p>
|
||||||
|
{% set role = item.role %}
|
||||||
|
{% if role is mapping %}{% set role = role.translations.get(lang) or role.translations.get(default_lang) or '' %}{% endif %}
|
||||||
|
{% if role %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ role }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center text-gray-400 dark:text-gray-500">Coming soon</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% extends "storefront/base.html" %}
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
{# Dynamic title from CMS #}
|
{# Dynamic title from CMS #}
|
||||||
{% block title %}{{ page.title }}{% endblock %}
|
{% block title %}{{ page_title or page.title }}{% endblock %}
|
||||||
|
|
||||||
{# SEO from CMS #}
|
{# SEO from CMS #}
|
||||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
<div class="breadcrumb mb-6">
|
<div class="breadcrumb mb-6">
|
||||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
|
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Page Header #}
|
{# Page Header #}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
||||||
{{ page.title }}
|
{{ page_title or page.title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{# Optional: Show store override badge for debugging #}
|
{# Optional: Show store override badge for debugging #}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{# app/templates/store/landing-default.html #}
|
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
|
||||||
{# standalone #}
|
|
||||||
{# Default/Minimal Landing Page Template #}
|
{# Default/Minimal Landing Page Template #}
|
||||||
{% extends "storefront/base.html" %}
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ store.name }}{% endblock %}
|
{% block title %}{{ store.name }}{% endblock %}
|
||||||
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
|
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
|
|
||||||
{# Title #}
|
{# Title #}
|
||||||
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
{{ page.title or store.name }}
|
{{ page_title or store.name }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{# Tagline #}
|
{# Tagline #}
|
||||||
@@ -34,18 +33,31 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# CTA Button #}
|
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
|
||||||
|
{% set nav_items = storefront_nav.get('nav', []) %}
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<a href="{{ base_url }}"
|
{% if nav_items %}
|
||||||
|
{# Primary CTA: first nav item from enabled modules #}
|
||||||
|
<a href="{{ base_url }}{{ nav_items[0].route }}"
|
||||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||||
style="background-color: var(--color-primary)">
|
style="background-color: var(--color-primary)">
|
||||||
Browse Our Shop
|
{{ _(nav_items[0].label_key) }}
|
||||||
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||||
</a>
|
</a>
|
||||||
{% if page.content %}
|
{% else %}
|
||||||
|
{# Fallback: account link when no module nav items #}
|
||||||
|
<a href="{{ base_url }}account/login"
|
||||||
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||||
|
style="background-color: var(--color-primary)">
|
||||||
|
{{ _('cms.storefront.my_account') }}
|
||||||
|
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page and page.content %}
|
||||||
<a href="#about"
|
<a href="#about"
|
||||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
|
||||||
Learn More
|
{{ _('cms.storefront.learn_more') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,73 +66,65 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Content Section (if provided) #}
|
{# Content Section (if provided) #}
|
||||||
{% if page.content %}
|
{% if page_content %}
|
||||||
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
||||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
{{ page_content | safe }}{# sanitized: CMS content #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Quick Links Section #}
|
{# Quick Links Section — driven by nav items and CMS pages #}
|
||||||
|
{% set account_items = storefront_nav.get('account', []) %}
|
||||||
|
{% set all_links = nav_items + account_items %}
|
||||||
|
{% if all_links or header_pages %}
|
||||||
<section class="py-16 bg-gray-50 dark:bg-gray-800">
|
<section class="py-16 bg-gray-50 dark:bg-gray-800">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
||||||
Explore
|
{{ _('cms.storefront.explore') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<a href="{{ base_url }}products"
|
{# Module nav items (products, loyalty, etc.) #}
|
||||||
|
{% for item in all_links[:3] %}
|
||||||
|
<a href="{{ base_url }}{{ item.route }}"
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||||
<div class="text-4xl mb-4">🛍️</div>
|
<div class="mb-4">
|
||||||
|
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||||
|
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
|
||||||
|
</div>
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||||
Shop Products
|
{{ _(item.label_key) }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Browse our complete catalog
|
|
||||||
</p>
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if header_pages %}
|
{# Fill remaining slots with CMS header pages #}
|
||||||
{% for page in header_pages[:2] %}
|
{% set remaining = 3 - all_links[:3]|length %}
|
||||||
|
{% if remaining > 0 and header_pages %}
|
||||||
|
{% for page in header_pages[:remaining] %}
|
||||||
<a href="{{ base_url }}{{ page.slug }}"
|
<a href="{{ base_url }}{{ page.slug }}"
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||||
<div class="text-4xl mb-4">📄</div>
|
<div class="mb-4">
|
||||||
|
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||||
|
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
|
||||||
|
</div>
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
{% if page.meta_description %}
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
{{ page.meta_description or 'Learn more' }}
|
{{ page.meta_description }}
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
|
||||||
<a href="{{ base_url }}about"
|
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
|
||||||
<div class="text-4xl mb-4">ℹ️</div>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
|
||||||
About Us
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Learn about our story
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="{{ base_url }}contact"
|
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
|
||||||
<div class="text-4xl mb-4">📧</div>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
|
||||||
Contact
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Get in touch with us
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,6 +10,35 @@
|
|||||||
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
|
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
||||||
|
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
|
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
|
||||||
|
{% if sections %}
|
||||||
|
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||||
|
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||||
|
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||||
|
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||||
|
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||||
|
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||||
|
|
||||||
|
{% set lang = request.state.language|default("fr") %}
|
||||||
|
{% set default_lang = 'fr' %}
|
||||||
|
|
||||||
|
<div class="min-h-screen">
|
||||||
|
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
|
{# HARDCODED LAYOUT (original full landing page — no sections JSON) #}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
|
|
||||||
{# Hero Section - Split Design #}
|
{# Hero Section - Split Design #}
|
||||||
@@ -255,4 +284,5 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Config File Pattern:
|
|||||||
api_timeout: int = Field(default=30, description="API timeout in seconds")
|
api_timeout: int = Field(default=30, description="API timeout in seconds")
|
||||||
max_retries: int = Field(default=3, description="Max retry attempts")
|
max_retries: int = Field(default=3, description="Max retry attempts")
|
||||||
|
|
||||||
model_config = {"env_prefix": "MYMODULE_"}
|
model_config = {"env_prefix": "MYMODULE_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
# Export the config class and instance
|
# Export the config class and instance
|
||||||
config_class = MyModuleConfig
|
config_class = MyModuleConfig
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Usage:
|
|||||||
|
|
||||||
# 2. Register in module definition
|
# 2. Register in module definition
|
||||||
def _get_onboarding_provider():
|
def _get_onboarding_provider():
|
||||||
from app.modules.marketplace.services.marketplace_onboarding import (
|
from app.modules.marketplace.services.marketplace_onboarding_service import (
|
||||||
marketplace_onboarding_provider,
|
marketplace_onboarding_provider,
|
||||||
)
|
)
|
||||||
return marketplace_onboarding_provider
|
return marketplace_onboarding_provider
|
||||||
|
|||||||
@@ -80,6 +80,44 @@ class WidgetContext:
|
|||||||
include_details: bool = False
|
include_details: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Storefront Dashboard Card
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StorefrontDashboardCard:
|
||||||
|
"""
|
||||||
|
A card contributed by a module to the storefront customer dashboard.
|
||||||
|
|
||||||
|
Modules implement get_storefront_dashboard_cards() to provide these.
|
||||||
|
The dashboard template renders them without knowing which module provided them.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
|
||||||
|
icon: Lucide icon name (e.g. "shopping-bag", "gift")
|
||||||
|
title: Card title (i18n key or plain text)
|
||||||
|
subtitle: Card subtitle / description
|
||||||
|
route: Link destination relative to base_url (e.g. "account/orders")
|
||||||
|
value: Primary display value (e.g. order count, points balance)
|
||||||
|
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
|
||||||
|
order: Sort order (lower = shown first)
|
||||||
|
template: Optional custom template path for complex rendering
|
||||||
|
extra_data: Additional data for custom template rendering
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
icon: str
|
||||||
|
title: str
|
||||||
|
subtitle: str
|
||||||
|
route: str
|
||||||
|
value: str | int | None = None
|
||||||
|
value_label: str | None = None
|
||||||
|
order: int = 100
|
||||||
|
template: str | None = None
|
||||||
|
extra_data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Widget Item Types
|
# Widget Item Types
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def get_storefront_dashboard_cards(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
store_id: int,
|
||||||
|
customer_id: int,
|
||||||
|
context: WidgetContext | None = None,
|
||||||
|
) -> list["StorefrontDashboardCard"]:
|
||||||
|
"""
|
||||||
|
Get cards for the storefront customer dashboard.
|
||||||
|
|
||||||
|
Called by the customer account dashboard. Each module contributes
|
||||||
|
its own cards (e.g. orders summary, loyalty points).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session for queries
|
||||||
|
store_id: ID of the store
|
||||||
|
customer_id: ID of the logged-in customer
|
||||||
|
context: Optional filtering/scoping context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of StorefrontDashboardCard objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Context
|
# Context
|
||||||
@@ -343,6 +405,8 @@ __all__ = [
|
|||||||
"WidgetData",
|
"WidgetData",
|
||||||
# Main envelope
|
# Main envelope
|
||||||
"DashboardWidget",
|
"DashboardWidget",
|
||||||
|
# Storefront
|
||||||
|
"StorefrontDashboardCard",
|
||||||
# Protocol
|
# Protocol
|
||||||
"DashboardWidgetProviderProtocol",
|
"DashboardWidgetProviderProtocol",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -64,11 +64,13 @@ core_module = ModuleDefinition(
|
|||||||
menu_items={
|
menu_items={
|
||||||
FrontendType.ADMIN: [
|
FrontendType.ADMIN: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
|
"my_account",
|
||||||
"settings",
|
"settings",
|
||||||
"email-templates",
|
"email-templates",
|
||||||
],
|
],
|
||||||
FrontendType.STORE: [
|
FrontendType.STORE: [
|
||||||
"dashboard",
|
"dashboard",
|
||||||
|
"my_account",
|
||||||
"profile",
|
"profile",
|
||||||
"settings",
|
"settings",
|
||||||
"email-templates",
|
"email-templates",
|
||||||
@@ -97,6 +99,22 @@ core_module = ModuleDefinition(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
MenuSectionDefinition(
|
||||||
|
id="account",
|
||||||
|
label_key="core.menu.account",
|
||||||
|
icon="user",
|
||||||
|
order=890,
|
||||||
|
items=[
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="my_account",
|
||||||
|
label_key="core.menu.my_account",
|
||||||
|
icon="user-circle",
|
||||||
|
route="/admin/my-account",
|
||||||
|
order=5,
|
||||||
|
is_mandatory=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
MenuSectionDefinition(
|
MenuSectionDefinition(
|
||||||
id="settings",
|
id="settings",
|
||||||
label_key="core.menu.platform_settings",
|
label_key="core.menu.platform_settings",
|
||||||
@@ -158,9 +176,16 @@ core_module = ModuleDefinition(
|
|||||||
icon="user",
|
icon="user",
|
||||||
order=900,
|
order=900,
|
||||||
items=[
|
items=[
|
||||||
|
MenuItemDefinition(
|
||||||
|
id="my_account",
|
||||||
|
label_key="core.menu.my_account",
|
||||||
|
icon="user-circle",
|
||||||
|
route="/store/{store_code}/my-account",
|
||||||
|
order=5,
|
||||||
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="profile",
|
id="profile",
|
||||||
label_key="core.menu.profile",
|
label_key="core.menu.store_settings",
|
||||||
icon="user",
|
icon="user",
|
||||||
route="/store/{store_code}/profile",
|
route="/store/{store_code}/profile",
|
||||||
order=10,
|
order=10,
|
||||||
|
|||||||
@@ -65,8 +65,16 @@
|
|||||||
"profile_updated": "Profil erfolgreich aktualisiert"
|
"profile_updated": "Profil erfolgreich aktualisiert"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
"failed_to_load_dashboard_data": "Dashboard-Daten konnten nicht geladen werden",
|
||||||
"dashboard_refreshed": "Dashboard refreshed"
|
"dashboard_refreshed": "Dashboard aktualisiert",
|
||||||
|
"item_removed_from_cart": "Artikel aus dem Warenkorb entfernt",
|
||||||
|
"cart_cleared": "Warenkorb geleert"
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"show_all_menu_items": "Alle Menüpunkte werden angezeigt. Fortfahren?",
|
||||||
|
"hide_all_menu_items": "Alle Menüpunkte werden ausgeblendet (außer obligatorische). Sie können dann die gewünschten aktivieren. Fortfahren?",
|
||||||
|
"reset_email_settings": "Alle E-Mail-Einstellungen werden auf .env-Standardwerte zurückgesetzt. Fortfahren?",
|
||||||
|
"cleanup_logs": "Alle Protokolle, die älter als {days} Tage sind, werden gelöscht. Fortfahren?"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
|||||||
@@ -65,8 +65,16 @@
|
|||||||
"profile_updated": "Profil mis à jour avec succès"
|
"profile_updated": "Profil mis à jour avec succès"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
"failed_to_load_dashboard_data": "Échec du chargement des données du tableau de bord",
|
||||||
"dashboard_refreshed": "Dashboard refreshed"
|
"dashboard_refreshed": "Tableau de bord actualisé",
|
||||||
|
"item_removed_from_cart": "Article retiré du panier",
|
||||||
|
"cart_cleared": "Panier vidé"
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"show_all_menu_items": "Ceci affichera tous les éléments de menu. Continuer ?",
|
||||||
|
"hide_all_menu_items": "Ceci masquera tous les éléments de menu (sauf les obligatoires). Vous pourrez ensuite activer ceux que vous souhaitez. Continuer ?",
|
||||||
|
"reset_email_settings": "Ceci réinitialisera tous les paramètres e-mail aux valeurs par défaut .env. Continuer ?",
|
||||||
|
"cleanup_logs": "Ceci supprimera tous les journaux de plus de {days} jours. Continuer ?"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
|
|||||||
@@ -65,8 +65,16 @@
|
|||||||
"profile_updated": "Profil erfollegräich aktualiséiert"
|
"profile_updated": "Profil erfollegräich aktualiséiert"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
"failed_to_load_dashboard_data": "Dashboard-Donnéeë konnten net geluede ginn",
|
||||||
"dashboard_refreshed": "Dashboard refreshed"
|
"dashboard_refreshed": "Dashboard aktualiséiert",
|
||||||
|
"item_removed_from_cart": "Artikel aus dem Kuerf ewechgeholl",
|
||||||
|
"cart_cleared": "Kuerf eidel gemaach"
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"show_all_menu_items": "All Menüpunkten ginn ugewisen. Weidermaachen?",
|
||||||
|
"hide_all_menu_items": "All Menüpunkten ginn verstopp (ausser obligatoresch). Dir kënnt dann déi gewënscht aktivéieren. Weidermaachen?",
|
||||||
|
"reset_email_settings": "All E-Mail-Astellunge ginn op .env-Standardwäerter zréckgesat. Weidermaachen?",
|
||||||
|
"cleanup_logs": "All Protokoller, déi méi al wéi {days} Deeg sinn, ginn geläscht. Weidermaachen?"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
|||||||
@@ -143,9 +143,11 @@ def get_onboarding_status(
|
|||||||
store_id = current_user.token_store_id
|
store_id = current_user.token_store_id
|
||||||
store = store_service.get_store_by_id(db, store_id)
|
store = store_service.get_store_by_id(db, store_id)
|
||||||
|
|
||||||
return onboarding_aggregator.get_onboarding_summary(
|
summary = onboarding_aggregator.get_onboarding_summary(
|
||||||
db=db,
|
db=db,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
platform_id=platform.id,
|
platform_id=platform.id,
|
||||||
store_code=store.store_code,
|
store_code=store.store_code,
|
||||||
)
|
)
|
||||||
|
summary["is_owner"] = current_user.role == "merchant_owner"
|
||||||
|
return summary
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ async def get_rendered_store_menu(
|
|||||||
# Platform from JWT (set at login from URL context), fall back to DB for old tokens
|
# Platform from JWT (set at login from URL context), fall back to DB for old tokens
|
||||||
platform_id = current_user.token_platform_id
|
platform_id = current_user.token_platform_id
|
||||||
if platform_id is None:
|
if platform_id is None:
|
||||||
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
|
platform_id = menu_service.get_store_fallback_platform_id(db, store.id)
|
||||||
|
|
||||||
# Get filtered menu with platform visibility, store_code, and permission filtering
|
# Get filtered menu with platform visibility, store_code, and permission filtering
|
||||||
menu = menu_service.get_menu_for_rendering(
|
menu = menu_service.get_menu_for_rendering(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async def admin_login_page(
|
|||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"current_language": language,
|
"current_language": language,
|
||||||
|
"frontend_type": "admin",
|
||||||
**get_jinja2_globals(language),
|
**get_jinja2_globals(language),
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async def merchant_login_page(
|
|||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"current_language": language,
|
"current_language": language,
|
||||||
|
"frontend_type": "merchant",
|
||||||
**get_jinja2_globals(language),
|
**get_jinja2_globals(language),
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user