Compare commits
150 Commits
395707951e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -111,11 +111,9 @@ language_rules:
|
||||
function languageSelector(currentLang, enabledLanguages) { ... }
|
||||
window.languageSelector = languageSelector;
|
||||
pattern:
|
||||
file_pattern: "static/shop/js/shop-layout.js"
|
||||
required_patterns:
|
||||
- "function languageSelector"
|
||||
- "window.languageSelector"
|
||||
file_pattern: "static/vendor/js/init-alpine.js"
|
||||
file_patterns:
|
||||
- "static/shop/js/shop-layout.js"
|
||||
- "static/vendor/js/init-alpine.js"
|
||||
required_patterns:
|
||||
- "function languageSelector"
|
||||
- "window.languageSelector"
|
||||
@@ -247,3 +245,26 @@ language_rules:
|
||||
pattern:
|
||||
file_pattern: "static/locales/*.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
|
||||
de.json
|
||||
fr.json
|
||||
lu.json
|
||||
lb.json
|
||||
|
||||
Translation keys are namespaced as {module}.key_name
|
||||
pattern:
|
||||
@@ -269,14 +269,14 @@ module_rules:
|
||||
Module locales/ directory should have translation files for
|
||||
all supported languages to ensure consistent i18n.
|
||||
|
||||
Supported languages: en, de, fr, lu
|
||||
Supported languages: en, de, fr, lb
|
||||
|
||||
Structure:
|
||||
app/modules/<code>/locales/
|
||||
├── en.json
|
||||
├── de.json
|
||||
├── fr.json
|
||||
└── lu.json
|
||||
└── lb.json
|
||||
|
||||
Missing translations will fall back to English, but it's
|
||||
better to have all languages covered.
|
||||
@@ -286,7 +286,7 @@ module_rules:
|
||||
- "en.json"
|
||||
- "de.json"
|
||||
- "fr.json"
|
||||
- "lu.json"
|
||||
- "lb.json"
|
||||
|
||||
- id: "MOD-007"
|
||||
name: "Module definition must match directory structure"
|
||||
|
||||
15
.env.example
15
.env.example
@@ -67,10 +67,15 @@ LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/app.log
|
||||
|
||||
# =============================================================================
|
||||
# PLATFORM DOMAIN CONFIGURATION
|
||||
# MAIN DOMAIN CONFIGURATION
|
||||
# =============================================================================
|
||||
# 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
|
||||
# Enable/disable custom domains
|
||||
@@ -223,7 +228,11 @@ R2_BACKUP_BUCKET=orion-backups
|
||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||
# Get Issuer ID from https://pay.google.com/business/console
|
||||
# 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)
|
||||
|
||||
@@ -37,10 +37,11 @@ jobs:
|
||||
run: ruff check .
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# Tests — unit only (integration tests run locally via make test)
|
||||
# ---------------------------------------------------------------------------
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -55,10 +56,9 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
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"
|
||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||
LOG_LEVEL: "WARNING"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -73,8 +73,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
run: python -m pytest tests/ -v --tb=short
|
||||
- name: Run unit tests
|
||||
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:
|
||||
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__/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
.dockerignore.local
|
||||
*.override.yml
|
||||
|
||||
# Deployment & Security
|
||||
.build-info
|
||||
deployment-local/
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
32
Makefile
32
Makefile
@@ -1,7 +1,7 @@
|
||||
# Orion Multi-Tenant E-Commerce Platform Makefile
|
||||
# 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
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -249,24 +249,21 @@ ifdef frontend
|
||||
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:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v $(MARKER_EXPR)
|
||||
$(PYTHON) -m pytest -v $(MARKER_EXPR)
|
||||
|
||||
test-unit:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
ifdef module
|
||||
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
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m unit
|
||||
$(PYTHON) -m pytest -v -m unit
|
||||
endif
|
||||
|
||||
test-integration:
|
||||
@@ -274,29 +271,38 @@ test-integration:
|
||||
@sleep 2
|
||||
ifdef module
|
||||
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
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m integration
|
||||
$(PYTHON) -m pytest -v -m integration
|
||||
endif
|
||||
|
||||
test-coverage:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
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:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
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:
|
||||
@docker compose -f docker-compose.test.yml up -d 2>/dev/null || true
|
||||
@sleep 2
|
||||
TEST_DATABASE_URL="$(TEST_DB_URL)" \
|
||||
$(PYTHON) -m pytest $(TEST_PATHS) -v -m slow
|
||||
$(PYTHON) -m pytest -v -m slow
|
||||
|
||||
# =============================================================================
|
||||
# CODE QUALITY
|
||||
@@ -569,6 +575,8 @@ help:
|
||||
@echo " test-unit module=X - Run unit tests for module X"
|
||||
@echo " test-integration - Run integration tests only"
|
||||
@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 frontend=storefront - Run storefront tests"
|
||||
@echo ""
|
||||
|
||||
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:
|
||||
# Invalid token, store mismatch, or other error
|
||||
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
|
||||
- Database settings
|
||||
- JWT and authentication configuration
|
||||
- Platform domain and multi-tenancy settings
|
||||
- Main domain and multi-tenancy settings
|
||||
- Admin initialization settings
|
||||
|
||||
Note: Environment detection is handled by app.core.environment module.
|
||||
@@ -94,9 +94,14 @@ class Settings(BaseSettings):
|
||||
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
|
||||
allow_custom_domains: bool = True
|
||||
@@ -217,14 +222,6 @@ class Settings(BaseSettings):
|
||||
# =============================================================================
|
||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||
|
||||
# =============================================================================
|
||||
# GOOGLE WALLET (LOYALTY MODULE)
|
||||
# =============================================================================
|
||||
loyalty_google_issuer_id: str | None = None
|
||||
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
||||
loyalty_google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT
|
||||
loyalty_default_logo_url: str = "https://rewardflow.lu/static/modules/loyalty/shared/img/default-logo-200.png"
|
||||
|
||||
# =============================================================================
|
||||
# APPLE WALLET (LOYALTY MODULE)
|
||||
# =============================================================================
|
||||
@@ -234,7 +231,7 @@ class Settings(BaseSettings):
|
||||
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
|
||||
@@ -353,7 +350,7 @@ def print_environment_info():
|
||||
print(f" Database: {settings.database_url}")
|
||||
print(f" Debug mode: {settings.debug}")
|
||||
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("=" * 70 + "\n")
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
from .config import settings, validate_database_url
|
||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
||||
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():
|
||||
"""
|
||||
Database session dependency for FastAPI routes.
|
||||
|
||||
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:
|
||||
HTMLResponse with rendered error page
|
||||
"""
|
||||
# Get frontend type
|
||||
frontend_type = get_frontend_type(request)
|
||||
# Get frontend type — default to PLATFORM in error rendering context
|
||||
# (errors can occur before FrontendTypeMiddleware runs)
|
||||
frontend_type = get_frontend_type(request) or FrontendType.PLATFORM
|
||||
|
||||
# Prepare template data
|
||||
template_data = ErrorPageRenderer._prepare_template_data(
|
||||
@@ -291,7 +292,7 @@ class ErrorPageRenderer:
|
||||
# TODO: Implement actual admin check based on JWT/session
|
||||
# For now, check if we're in admin frontend
|
||||
frontend_type = get_frontend_type(request)
|
||||
return frontend_type == FrontendType.ADMIN
|
||||
return frontend_type is not None and frontend_type == FrontendType.ADMIN
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
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:
|
||||
logger.debug("Redirecting to /admin/login")
|
||||
|
||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "ANALYTICS_"}
|
||||
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
# 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 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">
|
||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
||||
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
badge_source: str | None = None
|
||||
is_super_admin_only: bool = False
|
||||
header_template: str | None = None # Optional partial for custom header rendering
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "BILLING_"}
|
||||
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
|
||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
||||
store = billing_service.get_store(db, store_id)
|
||||
|
||||
# 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"
|
||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def create_checkout_session(
|
||||
|
||||
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"
|
||||
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||
|
||||
@@ -87,7 +87,7 @@ def create_portal_session(
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -617,7 +617,7 @@ class SignupService:
|
||||
|
||||
# Build login URL
|
||||
login_url = (
|
||||
f"https://{settings.platform_domain}"
|
||||
f"{settings.app_base_url.rstrip('/')}"
|
||||
f"/store/{store.store_code}/dashboard"
|
||||
)
|
||||
|
||||
|
||||
@@ -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="team_members", limit_value=5),
|
||||
]
|
||||
for f in features:
|
||||
db.add(f)
|
||||
db.add_all(features)
|
||||
db.commit()
|
||||
# Refresh so the tier's selectin-loaded feature_limits relationship is up to date
|
||||
db.refresh(ft_tier)
|
||||
|
||||
@@ -83,13 +83,12 @@ def billing_extra_platforms(db):
|
||||
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
||||
platforms = []
|
||||
for i in range(2):
|
||||
p = Platform(
|
||||
platforms.append(Platform(
|
||||
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
||||
name=f"Extra Platform {i}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(p)
|
||||
platforms.append(p)
|
||||
))
|
||||
db.add_all(platforms)
|
||||
db.commit()
|
||||
for p in platforms:
|
||||
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_c", limit_value=50),
|
||||
]
|
||||
for f in features:
|
||||
db.add(f)
|
||||
db.add_all(features)
|
||||
db.commit()
|
||||
return features
|
||||
|
||||
|
||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="cart",
|
||||
label_key="storefront.actions.cart",
|
||||
label_key="cart.storefront.actions.cart",
|
||||
icon="shopping-cart",
|
||||
route="cart",
|
||||
order=20,
|
||||
header_template="cart/storefront/partials/header-cart.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||
"manage": "Warenkörbe verwalten",
|
||||
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Warenkorb"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,53 @@
|
||||
{
|
||||
"title": "Shopping Cart",
|
||||
"description": "Shopping cart management for customers",
|
||||
"cart": {
|
||||
"title": "Your Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"empty_subtitle": "Add items to start shopping",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"proceed_to_checkout": "Proceed to Checkout"
|
||||
},
|
||||
"item": {
|
||||
"product": "Product",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"total": "Total",
|
||||
"remove": "Remove",
|
||||
"update": "Update"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Order Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"estimated_shipping": "Calculated at checkout",
|
||||
"tax": "Tax",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Invalid quantity",
|
||||
"min_quantity": "Minimum quantity is {min}",
|
||||
"max_quantity": "Maximum quantity is {max}",
|
||||
"insufficient_inventory": "Only {available} available"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "View Carts",
|
||||
"view_desc": "View customer shopping carts",
|
||||
"manage": "Manage Carts",
|
||||
"manage_desc": "Modify and manage customer carts"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Item added to cart",
|
||||
"item_updated": "Cart updated",
|
||||
"item_removed": "Item removed from cart",
|
||||
"cart_cleared": "Cart cleared",
|
||||
"product_not_available": "Product not available",
|
||||
"error_adding": "Error adding item to cart",
|
||||
"error_updating": "Error updating cart"
|
||||
}
|
||||
"title": "Shopping Cart",
|
||||
"description": "Shopping cart management for customers",
|
||||
"cart": {
|
||||
"title": "Your Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"empty_subtitle": "Add items to start shopping",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"proceed_to_checkout": "Proceed to Checkout"
|
||||
},
|
||||
"item": {
|
||||
"product": "Product",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"total": "Total",
|
||||
"remove": "Remove",
|
||||
"update": "Update"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Order Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"estimated_shipping": "Calculated at checkout",
|
||||
"tax": "Tax",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Invalid quantity",
|
||||
"min_quantity": "Minimum quantity is {min}",
|
||||
"max_quantity": "Maximum quantity is {max}",
|
||||
"insufficient_inventory": "Only {available} available"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "View Carts",
|
||||
"view_desc": "View customer shopping carts",
|
||||
"manage": "Manage Carts",
|
||||
"manage_desc": "Modify and manage customer carts"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Item added to cart",
|
||||
"item_updated": "Cart updated",
|
||||
"item_removed": "Item removed from cart",
|
||||
"cart_cleared": "Cart cleared",
|
||||
"product_not_available": "Product not available",
|
||||
"error_adding": "Error adding item to cart",
|
||||
"error_updating": "Error updating cart"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Cart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Voir les paniers des clients",
|
||||
"manage": "Gérer les paniers",
|
||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Panier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Clientekuerf kucken",
|
||||
"manage": "Kuerf verwalten",
|
||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Kuerf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
console.log('[SHOP] Cart page initializing...');
|
||||
console.log('[STOREFRONT] Cart page initializing...');
|
||||
|
||||
// Call parent init to set up sessionId
|
||||
if (baseData.init) {
|
||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
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}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.items = data.items || [];
|
||||
this.cartCount = this.totalItems;
|
||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
||||
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||
}
|
||||
} 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');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
||||
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||
const response = await fetch(
|
||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to update quantity');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Update quantity error:', error);
|
||||
console.error('[STOREFRONT] Update quantity error:', error);
|
||||
this.showToast('Failed to update quantity', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Removing item:', productId);
|
||||
console.log('[STOREFRONT] Removing item:', productId);
|
||||
const response = await fetch(
|
||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||
{
|
||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to remove item');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Remove item error:', error);
|
||||
console.error('[STOREFRONT] Remove item error:', error);
|
||||
this.showToast('Failed to remove item', 'error');
|
||||
} finally {
|
||||
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=[
|
||||
MenuItemDefinition(
|
||||
id="products",
|
||||
label_key="storefront.nav.products",
|
||||
label_key="catalog.storefront.nav.products",
|
||||
icon="shopping-bag",
|
||||
route="products",
|
||||
order=10,
|
||||
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="search",
|
||||
label_key="storefront.actions.search",
|
||||
label_key="catalog.storefront.actions.search",
|
||||
icon="search",
|
||||
route="",
|
||||
order=10,
|
||||
header_template="catalog/storefront/partials/header-search.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Massenimport von Produkten",
|
||||
"products_export": "Produkte exportieren",
|
||||
"products_export_desc": "Produktdaten exportieren"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produkte"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,5 +107,13 @@
|
||||
"menu": {
|
||||
"products_inventory": "Products & Inventory",
|
||||
"all_products": "All Products"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Products"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Importation en masse de produits",
|
||||
"products_export": "Exporter les 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_export": "Produiten 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.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.
|
||||
|
||||
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)
|
||||
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.
|
||||
"""
|
||||
logger.debug(
|
||||
|
||||
@@ -192,9 +192,11 @@ class ProductService:
|
||||
True if deleted
|
||||
"""
|
||||
try:
|
||||
from app.core.soft_delete import soft_delete
|
||||
|
||||
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")
|
||||
return True
|
||||
|
||||
@@ -187,8 +187,8 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Category page initializing...');
|
||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
||||
console.log('[STOREFRONT] Category page initializing...');
|
||||
console.log('[STOREFRONT] Category slug:', this.categorySlug);
|
||||
|
||||
// Convert slug to display name
|
||||
this.categoryName = this.categorySlug
|
||||
@@ -213,7 +213,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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}`);
|
||||
|
||||
@@ -223,12 +223,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
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.total = data.total;
|
||||
} 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');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -243,7 +243,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -262,16 +262,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
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.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
async init() {
|
||||
console.log('[SHOP] Product detail page initializing...');
|
||||
console.log('[STOREFRONT] Product detail page initializing...');
|
||||
|
||||
// Call parent init to set up sessionId
|
||||
if (baseData.init) {
|
||||
baseData.init.call(this);
|
||||
}
|
||||
|
||||
console.log('[SHOP] Product ID:', this.productId);
|
||||
console.log('[SHOP] Store ID:', this.storeId);
|
||||
console.log('[SHOP] Session ID:', this.sessionId);
|
||||
console.log('[STOREFRONT] Product ID:', this.productId);
|
||||
console.log('[STOREFRONT] Store ID:', this.storeId);
|
||||
console.log('[STOREFRONT] Session ID:', this.sessionId);
|
||||
|
||||
await this.loadProduct();
|
||||
},
|
||||
@@ -275,7 +275,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
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}`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -283,7 +283,7 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
|
||||
this.product = await response.json();
|
||||
console.log('[SHOP] Product loaded:', this.product);
|
||||
console.log('[STOREFRONT] Product loaded:', this.product);
|
||||
|
||||
// Set default image
|
||||
if (this.product?.marketplace_product?.image_link) {
|
||||
@@ -297,7 +297,7 @@ document.addEventListener('alpine:init', () => {
|
||||
await this.loadRelatedProducts();
|
||||
|
||||
} 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');
|
||||
// Redirect back to products after error
|
||||
setTimeout(() => {
|
||||
@@ -320,10 +320,10 @@ document.addEventListener('alpine:init', () => {
|
||||
.filter(p => p.id !== parseInt(this.productId))
|
||||
.slice(0, 4);
|
||||
|
||||
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
||||
console.log('[STOREFRONT] Loaded related products:', this.relatedProducts.length);
|
||||
}
|
||||
} 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
|
||||
async addToCart() {
|
||||
if (!this.canAddToCart) {
|
||||
console.warn('[SHOP] Cannot add to cart:', {
|
||||
console.warn('[STOREFRONT] Cannot add to cart:', {
|
||||
canAddToCart: this.canAddToCart,
|
||||
isActive: this.product?.is_active,
|
||||
inventory: this.product?.available_inventory,
|
||||
@@ -374,7 +374,7 @@ document.addEventListener('alpine:init', () => {
|
||||
quantity: this.quantity
|
||||
};
|
||||
|
||||
console.log('[SHOP] Adding to cart:', {
|
||||
console.log('[STOREFRONT] Adding to cart:', {
|
||||
url,
|
||||
sessionId: this.sessionId,
|
||||
productId: this.productId,
|
||||
@@ -390,14 +390,14 @@ document.addEventListener('alpine:init', () => {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log('[SHOP] Add to cart response:', {
|
||||
console.log('[STOREFRONT] Add to cart response:', {
|
||||
status: response.status,
|
||||
ok: response.ok
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
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.showToast(
|
||||
@@ -409,11 +409,11 @@ document.addEventListener('alpine:init', () => {
|
||||
this.quantity = this.product?.min_quantity || 1;
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
} 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');
|
||||
} finally {
|
||||
this.addingToCart = false;
|
||||
|
||||
@@ -160,7 +160,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Products page initializing...');
|
||||
console.log('[STOREFRONT] Products page initializing...');
|
||||
await this.loadProducts();
|
||||
},
|
||||
|
||||
@@ -178,7 +178,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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}`);
|
||||
|
||||
@@ -188,12 +188,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
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.pagination.total = data.total;
|
||||
} 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');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -227,16 +227,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
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.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Search page initializing...');
|
||||
console.log('[STOREFRONT] Search page initializing...');
|
||||
|
||||
// Check for query parameter in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -254,7 +254,7 @@ document.addEventListener('alpine:init', () => {
|
||||
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}`);
|
||||
|
||||
@@ -264,12 +264,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
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.total = data.total;
|
||||
} catch (error) {
|
||||
console.error('[SHOP] Search failed:', error);
|
||||
console.error('[STOREFRONT] Search failed:', error);
|
||||
this.showToast('Search failed. Please try again.', 'error');
|
||||
this.products = [];
|
||||
this.total = 0;
|
||||
@@ -289,7 +289,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -308,16 +308,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
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.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ document.addEventListener('alpine:init', () => {
|
||||
isLoggedIn: false,
|
||||
|
||||
async init() {
|
||||
console.log('[SHOP] Wishlist page initializing...');
|
||||
console.log('[STOREFRONT] Wishlist page initializing...');
|
||||
|
||||
// Check if user is logged in
|
||||
this.isLoggedIn = await this.checkLoginStatus();
|
||||
@@ -168,7 +168,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log('[SHOP] Loading wishlist...');
|
||||
console.log('[STOREFRONT] Loading wishlist...');
|
||||
|
||||
const response = await fetch('/api/v1/storefront/wishlist');
|
||||
|
||||
@@ -182,11 +182,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
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 || [];
|
||||
} 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');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -195,7 +195,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
async removeFromWishlist(item) {
|
||||
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}`, {
|
||||
method: 'DELETE'
|
||||
@@ -208,13 +208,13 @@ document.addEventListener('alpine:init', () => {
|
||||
throw new Error('Failed to remove from wishlist');
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
},
|
||||
|
||||
async addToCart(product) {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
console.log('[STOREFRONT] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
@@ -233,16 +233,16 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (response.ok) {
|
||||
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.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "CMS_"}
|
||||
model_config = {"env_prefix": "CMS_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"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",
|
||||
"store_themes": "Store Themes",
|
||||
"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": {
|
||||
"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": {
|
||||
"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
|
||||
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
|
||||
is_published = Column(Boolean, default=False, nullable=False)
|
||||
@@ -230,6 +235,16 @@ class ContentPage(Base):
|
||||
)
|
||||
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):
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
@@ -248,7 +263,7 @@ class ContentPage(Base):
|
||||
"template": self.template,
|
||||
"sections": self.sections,
|
||||
"meta_description": self.meta_description,
|
||||
"meta_keywords": self.meta_keywords,
|
||||
"meta_description_translations": self.meta_description_translations,
|
||||
"is_published": self.is_published,
|
||||
"published_at": (
|
||||
self.published_at.isoformat() if self.published_at else None
|
||||
|
||||
@@ -73,7 +73,7 @@ def create_platform_page(
|
||||
content_format=page_data.content_format,
|
||||
template=page_data.template,
|
||||
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,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
@@ -117,7 +117,7 @@ def create_store_page(
|
||||
content_format=page_data.content_format,
|
||||
template=page_data.template,
|
||||
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,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
@@ -177,11 +177,13 @@ def update_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=page_data.title,
|
||||
title_translations=page_data.title_translations,
|
||||
content=page_data.content,
|
||||
content_translations=page_data.content_translations,
|
||||
content_format=page_data.content_format,
|
||||
template=page_data.template,
|
||||
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,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
|
||||
@@ -207,7 +207,7 @@ def create_store_page(
|
||||
store_id=current_user.token_store_id,
|
||||
content_format=page_data.content_format,
|
||||
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,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
@@ -241,7 +241,7 @@ def update_store_page(
|
||||
content=page_data.content,
|
||||
content_format=page_data.content_format,
|
||||
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,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
|
||||
@@ -20,7 +20,8 @@ from app.modules.cms.services import content_page_service
|
||||
from app.modules.core.services.platform_settings_service import (
|
||||
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
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
@@ -103,14 +176,25 @@ async def generic_content_page(
|
||||
|
||||
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
||||
page_content = page.content
|
||||
page_title = page.title
|
||||
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)
|
||||
|
||||
context = get_storefront_context(request, db=db, page=page)
|
||||
context["page_title"] = page_title
|
||||
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(
|
||||
"cms/storefront/content-page.html",
|
||||
template_name,
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
|
||||
description="URL-safe identifier (about, faq, contact, etc.)",
|
||||
)
|
||||
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_translations: dict[str, str] | None = Field(
|
||||
None, description="Content translations keyed by language code"
|
||||
)
|
||||
content_format: str = Field(
|
||||
default="html", description="Content format: html or markdown"
|
||||
)
|
||||
template: str = Field(
|
||||
default="default",
|
||||
max_length=50,
|
||||
description="Template name (default, minimal, modern)",
|
||||
description="Template name (default, minimal, modern, full)",
|
||||
)
|
||||
meta_description: str | None = Field(
|
||||
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")
|
||||
show_in_footer: bool = Field(default=True, description="Show in footer 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)."""
|
||||
|
||||
title: str | None = Field(None, max_length=200)
|
||||
title_translations: dict[str, str] | None = None
|
||||
content: str | None = None
|
||||
content_translations: dict[str, str] | None = None
|
||||
content_format: str | None = None
|
||||
template: str | None = Field(None, max_length=50)
|
||||
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
|
||||
show_in_footer: bool | None = None
|
||||
show_in_header: bool | None = None
|
||||
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
|
||||
store_name: str | None
|
||||
slug: str
|
||||
title: str
|
||||
title_translations: dict[str, str] | None = None
|
||||
content: str
|
||||
content_translations: dict[str, str] | None = None
|
||||
content_format: str
|
||||
template: str | None = None
|
||||
meta_description: str | None
|
||||
meta_keywords: str | None
|
||||
meta_description_translations: dict[str, str] | None = None
|
||||
is_published: bool
|
||||
published_at: str | None
|
||||
display_order: int
|
||||
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
|
||||
meta_description: str | None = Field(
|
||||
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")
|
||||
show_in_footer: bool = Field(default=True, description="Show in footer 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_format: str | None = None
|
||||
meta_description: str | None = Field(None, max_length=300)
|
||||
meta_keywords: str | None = Field(None, max_length=300)
|
||||
is_published: bool | None = None
|
||||
show_in_footer: bool | None = None
|
||||
show_in_header: bool | None = None
|
||||
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
|
||||
content: str
|
||||
content_format: str
|
||||
meta_description: str | None
|
||||
meta_keywords: str | None
|
||||
published_at: str | None
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -472,7 +473,7 @@ class ContentPageService:
|
||||
content_format: str = "html",
|
||||
template: str = "default",
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool = False,
|
||||
show_in_footer: bool = True,
|
||||
show_in_header: bool = False,
|
||||
@@ -494,7 +495,7 @@ class ContentPageService:
|
||||
content_format: "html" or "markdown"
|
||||
template: Template name for landing pages
|
||||
meta_description: SEO description
|
||||
meta_keywords: SEO keywords
|
||||
meta_description_translations: Meta description translations dict
|
||||
is_published: Publish immediately
|
||||
show_in_footer: Show in footer navigation
|
||||
show_in_header: Show in header navigation
|
||||
@@ -515,7 +516,7 @@ class ContentPageService:
|
||||
content_format=content_format,
|
||||
template=template,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
published_at=datetime.now(UTC) if is_published else None,
|
||||
show_in_footer=show_in_footer,
|
||||
@@ -541,11 +542,13 @@ class ContentPageService:
|
||||
db: Session,
|
||||
page_id: int,
|
||||
title: str | None = None,
|
||||
title_translations: dict[str, str] | None = None,
|
||||
content: str | None = None,
|
||||
content_translations: dict[str, str] | None = None,
|
||||
content_format: str | None = None,
|
||||
template: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
@@ -573,16 +576,20 @@ class ContentPageService:
|
||||
# Update fields if provided
|
||||
if title is not None:
|
||||
page.title = title
|
||||
if title_translations is not None:
|
||||
page.title_translations = title_translations
|
||||
if content is not None:
|
||||
page.content = content
|
||||
if content_translations is not None:
|
||||
page.content_translations = content_translations
|
||||
if content_format is not None:
|
||||
page.content_format = content_format
|
||||
if template is not None:
|
||||
page.template = template
|
||||
if meta_description is not None:
|
||||
page.meta_description = meta_description
|
||||
if meta_keywords is not None:
|
||||
page.meta_keywords = meta_keywords
|
||||
if meta_description_translations is not None:
|
||||
page.meta_description_translations = meta_description_translations
|
||||
if is_published is not None:
|
||||
page.is_published = is_published
|
||||
if is_published and not page.published_at:
|
||||
@@ -698,7 +705,7 @@ class ContentPageService:
|
||||
content: str | None = None,
|
||||
content_format: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
@@ -725,7 +732,7 @@ class ContentPageService:
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
@@ -760,7 +767,7 @@ class ContentPageService:
|
||||
content: str,
|
||||
content_format: str = "html",
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool = False,
|
||||
show_in_footer: bool = True,
|
||||
show_in_header: bool = False,
|
||||
@@ -791,7 +798,7 @@ class ContentPageService:
|
||||
is_platform_page=False,
|
||||
content_format=content_format,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
@@ -913,11 +920,13 @@ class ContentPageService:
|
||||
db: Session,
|
||||
page_id: int,
|
||||
title: str | None = None,
|
||||
title_translations: dict[str, str] | None = None,
|
||||
content: str | None = None,
|
||||
content_translations: dict[str, str] | None = None,
|
||||
content_format: str | None = None,
|
||||
template: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
@@ -935,11 +944,13 @@ class ContentPageService:
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=title,
|
||||
title_translations=title_translations,
|
||||
content=content,
|
||||
content_translations=content_translations,
|
||||
content_format=content_format,
|
||||
template=template,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
@@ -991,6 +1002,28 @@ class ContentPageService:
|
||||
content = content.replace(placeholder, value)
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
@@ -70,7 +70,7 @@ class StoreThemeService:
|
||||
"""
|
||||
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:
|
||||
self.logger.warning(f"Store not found: {store_code}")
|
||||
|
||||
@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
|
||||
form: {
|
||||
slug: '',
|
||||
title: '',
|
||||
title_translations: {},
|
||||
content: '',
|
||||
content_translations: {},
|
||||
content_format: 'html',
|
||||
template: 'default',
|
||||
meta_description: '',
|
||||
meta_keywords: '',
|
||||
meta_description_translations: {},
|
||||
is_published: false,
|
||||
show_in_header: false,
|
||||
show_in_footer: true,
|
||||
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
|
||||
error: null,
|
||||
successMessage: null,
|
||||
|
||||
// Page type: 'content' or 'landing'
|
||||
pageType: 'content',
|
||||
|
||||
// Translation language for title/content
|
||||
titleContentLang: 'fr',
|
||||
|
||||
// ========================================
|
||||
// HOMEPAGE SECTIONS STATE
|
||||
// ========================================
|
||||
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
|
||||
de: 'Deutsch',
|
||||
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: {
|
||||
hero: {
|
||||
enabled: true,
|
||||
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
|
||||
await this.loadPage();
|
||||
contentPageEditLog.groupEnd();
|
||||
|
||||
// Load sections if this is a homepage
|
||||
if (this.form.slug === 'home') {
|
||||
// Load sections if this is a landing page
|
||||
if (this.pageType === 'landing') {
|
||||
await this.loadSections();
|
||||
}
|
||||
} else {
|
||||
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
|
||||
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,
|
||||
|
||||
// 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() {
|
||||
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
|
||||
async loadPlatforms() {
|
||||
this.loadingPlatforms = true;
|
||||
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
|
||||
this.form = {
|
||||
slug: page.slug || '',
|
||||
title: page.title || '',
|
||||
title_translations: page.title_translations || {},
|
||||
content: page.content || '',
|
||||
content_translations: page.content_translations || {},
|
||||
content_format: page.content_format || 'html',
|
||||
template: page.template || 'default',
|
||||
meta_description: page.meta_description || '',
|
||||
meta_keywords: page.meta_keywords || '',
|
||||
meta_description_translations: page.meta_description_translations || {},
|
||||
is_published: page.is_published || false,
|
||||
show_in_header: page.show_in_header || false,
|
||||
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
|
||||
};
|
||||
|
||||
// Set page type from template
|
||||
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
|
||||
|
||||
contentPageEditLog.info('Page loaded successfully');
|
||||
|
||||
// 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() {
|
||||
if (!this.pageId || this.form.slug !== 'home') {
|
||||
contentPageEditLog.debug('Skipping section load - not a homepage');
|
||||
if (!this.pageId || this.pageType !== 'landing') {
|
||||
contentPageEditLog.debug('Skipping section load - not a landing page');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
contentPageEditLog.info('Loading homepage sections...');
|
||||
contentPageEditLog.info('Loading sections...');
|
||||
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
||||
const data = response.data || response;
|
||||
|
||||
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
||||
this.defaultLanguage = data.default_language || 'fr';
|
||||
this.currentLang = this.defaultLanguage;
|
||||
this.titleContentLang = this.defaultLanguage;
|
||||
|
||||
if (data.sections) {
|
||||
this.sections = this.mergeWithDefaults(data.sections);
|
||||
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
|
||||
mergeWithDefaults(loadedSections) {
|
||||
const defaults = this.getDefaultSectionStructure();
|
||||
|
||||
// Deep merge each section
|
||||
for (const key of ['hero', 'features', 'pricing', 'cta']) {
|
||||
// Deep merge each section that exists in defaults
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (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;
|
||||
},
|
||||
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
|
||||
|
||||
// Save sections
|
||||
async saveSections() {
|
||||
if (!this.pageId || !this.isHomepage) return;
|
||||
if (!this.pageId || this.pageType !== 'landing') return;
|
||||
|
||||
try {
|
||||
contentPageEditLog.info('Saving sections...');
|
||||
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
|
||||
const payload = {
|
||||
slug: this.form.slug,
|
||||
title: this.form.title,
|
||||
title_translations: this.form.title_translations,
|
||||
content: this.form.content,
|
||||
content_translations: this.form.content_translations,
|
||||
content_format: this.form.content_format,
|
||||
template: this.form.template,
|
||||
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,
|
||||
show_in_header: this.form.show_in_header,
|
||||
show_in_footer: this.form.show_in_footer,
|
||||
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
|
||||
// Update existing page
|
||||
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
||||
|
||||
// Also save sections if this is a homepage
|
||||
if (this.isHomepage && this.sectionsLoaded) {
|
||||
// Also save sections if this is a landing page
|
||||
if (this.pageType === 'landing' && this.sectionsLoaded) {
|
||||
await this.saveSections();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +57,23 @@
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Page Title -->
|
||||
<div class="md:col-span-2">
|
||||
<!-- Page Type -->
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.title"
|
||||
required
|
||||
maxlength="200"
|
||||
<select
|
||||
x-model="pageType"
|
||||
@change="updatePageType()"
|
||||
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>
|
||||
|
||||
<!-- Slug -->
|
||||
@@ -133,10 +137,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<!-- Title with Language Tabs -->
|
||||
<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">
|
||||
Page Content
|
||||
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</h3>
|
||||
|
||||
<!-- Content Format -->
|
||||
@@ -219,9 +267,9 @@
|
||||
</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">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Homepage Sections
|
||||
@@ -258,7 +306,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- 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
|
||||
type="button"
|
||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||
@@ -341,7 +389,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- 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
|
||||
type="button"
|
||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||
@@ -410,7 +458,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- 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
|
||||
type="button"
|
||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||
@@ -448,7 +496,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- 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
|
||||
type="button"
|
||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||
@@ -525,6 +573,7 @@
|
||||
<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">
|
||||
SEO & Metadata
|
||||
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -534,30 +583,17 @@
|
||||
Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
x-model="form.meta_description"
|
||||
:value="getTranslatedMetaDescription()"
|
||||
@input="setTranslatedMetaDescription($event.target.value)"
|
||||
rows="2"
|
||||
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="A brief description for search engines"
|
||||
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||
></textarea>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
{% from 'cms/platform/sections/_products.html' import render_products %}
|
||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||
{% 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 %}
|
||||
|
||||
{% block title %}
|
||||
@@ -51,6 +54,21 @@
|
||||
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
||||
{% 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 #}
|
||||
{% if page.sections.cta %}
|
||||
{{ 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" %}
|
||||
|
||||
{# Dynamic title from CMS #}
|
||||
{% block title %}{{ page.title }}{% endblock %}
|
||||
{% block title %}{{ page_title or page.title }}{% endblock %}
|
||||
|
||||
{# SEO from CMS #}
|
||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||
@@ -16,13 +16,13 @@
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<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>
|
||||
|
||||
{# Page Header #}
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
|
||||
{# Optional: Show store override badge for debugging #}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{# app/templates/store/landing-default.html #}
|
||||
{# standalone #}
|
||||
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
|
||||
{# Default/Minimal Landing Page Template #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% 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 %}
|
||||
<div class="min-h-screen">
|
||||
@@ -24,7 +23,7 @@
|
||||
|
||||
{# Title #}
|
||||
<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>
|
||||
|
||||
{# Tagline #}
|
||||
@@ -34,18 +33,31 @@
|
||||
</p>
|
||||
{% 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">
|
||||
<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"
|
||||
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>
|
||||
</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"
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -54,73 +66,65 @@
|
||||
</section>
|
||||
|
||||
{# Content Section (if provided) #}
|
||||
{% if page.content %}
|
||||
{% if page_content %}
|
||||
<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="prose prose-lg dark:prose-invert max-w-none">
|
||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
||||
{{ page_content | safe }}{# sanitized: CMS content #}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% 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">
|
||||
<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">
|
||||
Explore
|
||||
{{ _('cms.storefront.explore') }}
|
||||
</h2>
|
||||
<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">
|
||||
<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">
|
||||
Shop Products
|
||||
{{ _(item.label_key) }}
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Browse our complete catalog
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if header_pages %}
|
||||
{% for page in header_pages[:2] %}
|
||||
{# Fill remaining slots with CMS header pages #}
|
||||
{% 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 }}"
|
||||
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">
|
||||
{{ page.title }}
|
||||
</h3>
|
||||
{% if page.meta_description %}
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{{ page.meta_description or 'Learn more' }}
|
||||
{{ page.meta_description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,6 +10,35 @@
|
||||
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
||||
|
||||
{% 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">
|
||||
|
||||
{# Hero Section - Split Design #}
|
||||
@@ -255,4 +284,5 @@
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,7 +24,7 @@ Config File Pattern:
|
||||
api_timeout: int = Field(default=30, description="API timeout in seconds")
|
||||
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
|
||||
config_class = MyModuleConfig
|
||||
|
||||
@@ -80,6 +80,44 @@ class WidgetContext:
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -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__ = [
|
||||
# Context
|
||||
@@ -343,6 +405,8 @@ __all__ = [
|
||||
"WidgetData",
|
||||
# Main envelope
|
||||
"DashboardWidget",
|
||||
# Storefront
|
||||
"StorefrontDashboardCard",
|
||||
# Protocol
|
||||
"DashboardWidgetProviderProtocol",
|
||||
]
|
||||
|
||||
@@ -65,8 +65,16 @@
|
||||
"profile_updated": "Profil erfolgreich aktualisiert"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
||||
"dashboard_refreshed": "Dashboard refreshed"
|
||||
"failed_to_load_dashboard_data": "Dashboard-Daten konnten nicht geladen werden",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
|
||||
@@ -65,8 +65,16 @@
|
||||
"profile_updated": "Profil mis à jour avec succès"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
||||
"dashboard_refreshed": "Dashboard refreshed"
|
||||
"failed_to_load_dashboard_data": "Échec du chargement des données du tableau de bord",
|
||||
"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": {
|
||||
"dashboard": "Tableau de bord",
|
||||
|
||||
@@ -65,8 +65,16 @@
|
||||
"profile_updated": "Profil erfollegräich aktualiséiert"
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_load_dashboard_data": "Failed to load dashboard data",
|
||||
"dashboard_refreshed": "Dashboard refreshed"
|
||||
"failed_to_load_dashboard_data": "Dashboard-Donnéeë konnten net geluede ginn",
|
||||
"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": {
|
||||
"dashboard": "Dashboard",
|
||||
|
||||
@@ -62,6 +62,7 @@ async def admin_login_page(
|
||||
context = {
|
||||
"request": request,
|
||||
"current_language": language,
|
||||
"frontend_type": "admin",
|
||||
**get_jinja2_globals(language),
|
||||
}
|
||||
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
||||
|
||||
@@ -72,6 +72,7 @@ async def merchant_login_page(
|
||||
context = {
|
||||
"request": request,
|
||||
"current_language": language,
|
||||
"frontend_type": "merchant",
|
||||
**get_jinja2_globals(language),
|
||||
}
|
||||
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
||||
|
||||
@@ -9,11 +9,13 @@ Store pages for core functionality:
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
UserContext,
|
||||
get_current_store_from_cookie_or_header,
|
||||
get_current_store_optional,
|
||||
get_db,
|
||||
get_resolved_store_code,
|
||||
)
|
||||
@@ -24,6 +26,21 @@ from app.templates_config import templates
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE ROOT REDIRECT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def store_root(
|
||||
current_user: UserContext | None = Depends(get_current_store_optional),
|
||||
):
|
||||
"""Redirect /store/ based on authentication status."""
|
||||
if current_user:
|
||||
return RedirectResponse(url="/store/dashboard", status_code=302)
|
||||
return RedirectResponse(url="/store/login", status_code=302)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE DASHBOARD
|
||||
# ============================================================================
|
||||
|
||||
@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
|
||||
section_order: int
|
||||
is_visible: bool = True
|
||||
is_module_enabled: bool = True
|
||||
header_template: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
|
||||
section_label_key=section.label_key,
|
||||
section_order=section.order,
|
||||
is_module_enabled=is_module_enabled,
|
||||
header_template=item.header_template,
|
||||
)
|
||||
sections_map[section.id].items.append(discovered_item)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
||||
from app.modules.contracts.widgets import (
|
||||
DashboardWidget,
|
||||
DashboardWidgetProviderProtocol,
|
||||
StorefrontDashboardCard,
|
||||
WidgetContext,
|
||||
)
|
||||
|
||||
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_storefront_dashboard_cards(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
customer_id: int,
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[StorefrontDashboardCard]:
|
||||
"""
|
||||
Get dashboard cards for the storefront customer account page.
|
||||
|
||||
Collects cards from all enabled modules that implement
|
||||
get_storefront_dashboard_cards(), sorted by order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: ID of the store
|
||||
customer_id: ID of the logged-in customer
|
||||
platform_id: Platform ID (for module enablement check)
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
Flat list of StorefrontDashboardCard sorted by order
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
cards: list[StorefrontDashboardCard] = []
|
||||
|
||||
for module, provider in providers:
|
||||
if not hasattr(provider, "get_storefront_dashboard_cards"):
|
||||
continue
|
||||
try:
|
||||
module_cards = provider.get_storefront_dashboard_cards(
|
||||
db, store_id, customer_id, context
|
||||
)
|
||||
if module_cards:
|
||||
cards.extend(module_cards)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get storefront cards from module {module.code}: {e}"
|
||||
)
|
||||
|
||||
return sorted(cards, key=lambda c: c.order)
|
||||
|
||||
def get_available_categories(
|
||||
self, db: Session, platform_id: int
|
||||
) -> list[str]:
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
* Works with store-specific themes
|
||||
*/
|
||||
|
||||
const shopLog = {
|
||||
info: (...args) => console.info('🛒 [SHOP]', ...args),
|
||||
warn: (...args) => console.warn('⚠️ [SHOP]', ...args),
|
||||
error: (...args) => console.error('❌ [SHOP]', ...args),
|
||||
debug: (...args) => console.log('🔍 [SHOP]', ...args)
|
||||
const shopLog = window.LogConfig?.createLogger('STOREFRONT') || {
|
||||
info: (...args) => console.info('🛒 [STOREFRONT]', ...args),
|
||||
warn: (...args) => console.warn('⚠️ [STOREFRONT]', ...args),
|
||||
error: (...args) => console.error('❌ [STOREFRONT]', ...args),
|
||||
debug: (...args) => console.log('🔍 [STOREFRONT]', ...args)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,11 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
|
||||
from app.api.deps import (
|
||||
get_current_merchant_api,
|
||||
get_merchant_for_current_user,
|
||||
require_platform,
|
||||
)
|
||||
from app.modules.billing.models import (
|
||||
MerchantSubscription,
|
||||
SubscriptionStatus,
|
||||
@@ -108,15 +112,14 @@ def dash_team_members(db, dash_stores, dash_owner):
|
||||
auth = AuthManager()
|
||||
users = []
|
||||
for _ in range(2):
|
||||
u = User(
|
||||
users.append(User(
|
||||
email=f"dteam_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"dteam_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(u)
|
||||
users.append(u)
|
||||
))
|
||||
db.add_all(users)
|
||||
db.flush()
|
||||
|
||||
db.add(StoreUser(store_id=dash_stores[0].id, user_id=users[0].id, is_active=True))
|
||||
@@ -128,19 +131,19 @@ def dash_team_members(db, dash_stores, dash_owner):
|
||||
@pytest.fixture
|
||||
def dash_customers(db, dash_stores):
|
||||
"""Create customers in the merchant's stores."""
|
||||
customers = []
|
||||
for i in range(4):
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
db.add(
|
||||
Customer(
|
||||
store_id=dash_stores[0].id,
|
||||
email=f"dc_{uid}@test.com",
|
||||
hashed_password="hashed", # noqa: SEC001
|
||||
first_name=f"F{i}",
|
||||
last_name=f"L{i}",
|
||||
customer_number=f"DC{uid}",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
customers.append(Customer(
|
||||
store_id=dash_stores[0].id,
|
||||
email=f"dc_{uid}@test.com",
|
||||
hashed_password="hashed", # noqa: SEC001
|
||||
first_name=f"F{i}",
|
||||
last_name=f"L{i}",
|
||||
customer_number=f"DC{uid}",
|
||||
is_active=True,
|
||||
))
|
||||
db.add_all(customers)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -177,7 +180,7 @@ def dash_subscription(db, dash_merchant, dash_platform):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_auth(dash_owner, dash_merchant):
|
||||
def dash_auth(dash_owner, dash_merchant, dash_platform):
|
||||
"""Override auth dependencies for dashboard merchant."""
|
||||
user_context = UserContext(
|
||||
id=dash_owner.id,
|
||||
@@ -193,11 +196,16 @@ def dash_auth(dash_owner, dash_merchant):
|
||||
def _override_user():
|
||||
return user_context
|
||||
|
||||
def _override_platform():
|
||||
return dash_platform
|
||||
|
||||
app.dependency_overrides[get_merchant_for_current_user] = _override_merchant
|
||||
app.dependency_overrides[get_current_merchant_api] = _override_user
|
||||
app.dependency_overrides[require_platform] = _override_platform
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||
app.dependency_overrides.pop(require_platform, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -232,10 +240,14 @@ class TestMerchantDashboardStats:
|
||||
assert data["total_customers"] == 0
|
||||
assert data["team_members"] == 0
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
def test_requires_auth(self, client, dash_platform):
|
||||
"""Returns 401 without auth."""
|
||||
# Remove any overrides
|
||||
# Remove auth overrides but keep platform to isolate auth check
|
||||
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||
response = client.get(f"{BASE}/dashboard/stats")
|
||||
assert response.status_code == 401
|
||||
app.dependency_overrides[require_platform] = lambda: dash_platform
|
||||
try:
|
||||
response = client.get(f"{BASE}/dashboard/stats")
|
||||
assert response.status_code == 401
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_platform, None)
|
||||
|
||||
@@ -278,7 +278,7 @@ class TestMerchantMenuModuleGating:
|
||||
s for s in data["sections"] if s["id"] == platform_section_id
|
||||
)
|
||||
item_ids = {i["id"] for i in platform_section["items"]}
|
||||
assert "loyalty-overview" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_loyalty_hidden_when_module_not_enabled(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
@@ -296,7 +296,7 @@ class TestMerchantMenuModuleGating:
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
menu_loyalty_module, menu_platform,
|
||||
):
|
||||
"""Loyalty overview item has the correct URL."""
|
||||
"""Loyalty program item has the correct URL."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
data = response.json()
|
||||
platform_section_id = f"platform-{menu_platform.code}"
|
||||
@@ -304,9 +304,9 @@ class TestMerchantMenuModuleGating:
|
||||
s for s in data["sections"] if s["id"] == platform_section_id
|
||||
)
|
||||
overview = next(
|
||||
i for i in platform_section["items"] if i["id"] == "loyalty-overview"
|
||||
i for i in platform_section["items"] if i["id"] == "program"
|
||||
)
|
||||
assert overview["url"] == "/merchants/loyalty/overview"
|
||||
assert overview["url"] == "/merchants/loyalty/program"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -498,7 +498,7 @@ class TestMerchantMenuMultiPlatform:
|
||||
s for s in data["sections"] if s["id"] == platform_a_section_id
|
||||
)
|
||||
item_ids = {i["id"] for i in pa_section["items"]}
|
||||
assert "loyalty-overview" in item_ids
|
||||
assert "program" in item_ids
|
||||
# Core sections always present
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
|
||||
@@ -15,7 +15,7 @@ import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_store_api
|
||||
from app.api.deps import get_current_store_api, require_platform
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
@@ -108,7 +108,7 @@ def onb_store_platform(db, onb_store, onb_platform):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def onb_auth(onb_owner, onb_store):
|
||||
def onb_auth(onb_owner, onb_store, onb_platform):
|
||||
"""Override auth dependency for store API auth."""
|
||||
user_context = UserContext(
|
||||
id=onb_owner.id,
|
||||
@@ -123,9 +123,14 @@ def onb_auth(onb_owner, onb_store):
|
||||
def _override():
|
||||
return user_context
|
||||
|
||||
def _override_platform():
|
||||
return onb_platform
|
||||
|
||||
app.dependency_overrides[get_current_store_api] = _override
|
||||
app.dependency_overrides[require_platform] = _override_platform
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_current_store_api, None)
|
||||
app.dependency_overrides.pop(require_platform, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -206,10 +211,14 @@ class TestOnboardingEndpoint:
|
||||
if "/store/" in step["route"]:
|
||||
assert onb_store.store_code in step["route"]
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
def test_requires_auth(self, client, onb_platform):
|
||||
"""Returns 401 without authentication."""
|
||||
app.dependency_overrides.pop(get_current_store_api, None)
|
||||
response = client.get(
|
||||
"/api/v1/store/dashboard/onboarding",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
app.dependency_overrides[require_platform] = lambda: onb_platform
|
||||
try:
|
||||
response = client.get(
|
||||
"/api/v1/store/dashboard/onboarding",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_platform, None)
|
||||
|
||||
@@ -61,12 +61,12 @@ class TestMenuDiscoveryService:
|
||||
assert "profile" in item_ids
|
||||
|
||||
def test_merchant_loyalty_section_items(self):
|
||||
"""Loyalty section contains loyalty-overview."""
|
||||
"""Loyalty section contains program."""
|
||||
menus = self.service.discover_all_menus()
|
||||
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
|
||||
assert len(loyalty_sections) == 1
|
||||
item_ids = [i.id for i in loyalty_sections[0].items]
|
||||
assert "loyalty-overview" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_get_all_menu_items_merchant(self):
|
||||
"""get_all_menu_items returns items for MERCHANT frontend type."""
|
||||
@@ -75,7 +75,7 @@ class TestMenuDiscoveryService:
|
||||
item_ids = {i.id for i in items}
|
||||
assert "dashboard" in item_ids
|
||||
assert "subscriptions" in item_ids
|
||||
assert "loyalty-overview" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_get_all_menu_item_ids_merchant(self):
|
||||
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
|
||||
@@ -85,7 +85,7 @@ class TestMenuDiscoveryService:
|
||||
assert "invoices" in item_ids
|
||||
assert "stores" in item_ids
|
||||
assert "profile" in item_ids
|
||||
assert "loyalty-overview" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_get_mandatory_item_ids_merchant(self):
|
||||
"""Mandatory items for MERCHANT include dashboard and subscriptions."""
|
||||
|
||||
@@ -149,6 +149,9 @@ def get_context_for_frontend(
|
||||
# Pass enabled module codes to templates for conditional rendering
|
||||
context["enabled_modules"] = enabled_module_codes
|
||||
|
||||
# Pass frontend type to templates (used by JS for logging, dev toolbar, etc.)
|
||||
context["frontend_type"] = frontend_type.value
|
||||
|
||||
# For storefront, build nav menu structure from module declarations
|
||||
if frontend_type == FrontendType.STOREFRONT:
|
||||
from app.modules.core.services.menu_discovery_service import (
|
||||
@@ -205,7 +208,7 @@ def _build_base_context(
|
||||
"request": request,
|
||||
"platform": platform,
|
||||
"platform_name": settings.project_name,
|
||||
"platform_domain": settings.platform_domain,
|
||||
"main_domain": settings.main_domain,
|
||||
}
|
||||
|
||||
# Add i18n globals
|
||||
@@ -381,15 +384,17 @@ def get_storefront_context(
|
||||
if access_method == "path" and store:
|
||||
platform = getattr(request.state, "platform", None)
|
||||
platform_original_path = getattr(request.state, "platform_original_path", None)
|
||||
# Use subdomain (lowercase, hyphens) for URL routing — store_code is for internal use
|
||||
store_slug = store.subdomain or store.store_code
|
||||
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
|
||||
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
|
||||
base_url = f"/platforms/{platform.code}/storefront/{store_slug}/"
|
||||
else:
|
||||
full_prefix = (
|
||||
store_context.get("full_prefix", "/storefront/")
|
||||
if store_context
|
||||
else "/storefront/"
|
||||
)
|
||||
base_url = f"{full_prefix}{store.store_code}/"
|
||||
base_url = f"{full_prefix}{store_slug}/"
|
||||
|
||||
# Read subscription info set by StorefrontAccessMiddleware
|
||||
subscription = getattr(request.state, "subscription", None)
|
||||
|
||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
||||
# api_timeout: int = 30
|
||||
# batch_size: int = 100
|
||||
|
||||
model_config = {"env_prefix": "CUSTOMERS_"}
|
||||
model_config = {"env_prefix": "CUSTOMERS_", "env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
# Export for auto-discovery
|
||||
|
||||
@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="dashboard",
|
||||
label_key="storefront.account.dashboard",
|
||||
label_key="customers.storefront.account.dashboard",
|
||||
icon="home",
|
||||
route="account/dashboard",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="profile",
|
||||
label_key="storefront.account.profile",
|
||||
label_key="customers.storefront.account.profile",
|
||||
icon="user",
|
||||
route="account/profile",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="addresses",
|
||||
label_key="storefront.account.addresses",
|
||||
label_key="customers.storefront.account.addresses",
|
||||
icon="map-pin",
|
||||
route="account/addresses",
|
||||
order=30,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="settings",
|
||||
label_key="storefront.account.settings",
|
||||
label_key="customers.storefront.account.settings",
|
||||
icon="cog",
|
||||
route="account/settings",
|
||||
order=90,
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
"description": "Nachrichten an Kunden senden"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_toggle_customer_status": "Kundenstatus konnte nicht geändert werden",
|
||||
"failed_to_load_customer_details": "Kundendetails konnten nicht geladen werden",
|
||||
"failed_to_load_customer_orders": "Kundenbestellungen konnten nicht geladen werden"
|
||||
},
|
||||
"menu": {
|
||||
"store_operations": "Shop-Betrieb",
|
||||
"customers_section": "Kunden",
|
||||
@@ -47,5 +52,13 @@
|
||||
"customers_delete_desc": "Kundendatensätze entfernen",
|
||||
"customers_export": "Kunden exportieren",
|
||||
"customers_export_desc": "Kundendaten exportieren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adressen",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_section": "Customers",
|
||||
"customers": "Customers",
|
||||
"all_customers": "All Customers"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profile",
|
||||
"addresses": "Addresses",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
"description": "Envoyer des messages aux clients"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_toggle_customer_status": "Échec du changement de statut du client",
|
||||
"failed_to_load_customer_details": "Échec du chargement des détails du client",
|
||||
"failed_to_load_customer_orders": "Échec du chargement des commandes du client"
|
||||
},
|
||||
"menu": {
|
||||
"store_operations": "Opérations du magasin",
|
||||
"customers_section": "Clients",
|
||||
@@ -47,5 +52,13 @@
|
||||
"customers_delete_desc": "Supprimer les fiches clients",
|
||||
"customers_export": "Exporter les clients",
|
||||
"customers_export_desc": "Exporter les données clients"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adresses",
|
||||
"settings": "Paramètres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
"description": "Noriichten u Clienten schécken"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"failed_to_toggle_customer_status": "Clientestatus konnt net geännert ginn",
|
||||
"failed_to_load_customer_details": "Clientedetailer konnten net geluede ginn",
|
||||
"failed_to_load_customer_orders": "Clientebestellunge konnten net geluede ginn"
|
||||
},
|
||||
"menu": {
|
||||
"store_operations": "Buttek-Operatiounen",
|
||||
"customers_section": "Clienten",
|
||||
@@ -47,5 +52,13 @@
|
||||
"customers_delete_desc": "Clientedossieren ewechhuelen",
|
||||
"customers_export": "Clienten exportéieren",
|
||||
"customers_export_desc": "Clientedaten exportéieren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adressen",
|
||||
"settings": "Astellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""customers 003 - add birth_date column
|
||||
|
||||
Adds an optional birth_date column to the customers table so that
|
||||
self-enrollment flows (e.g. loyalty) can persist the customer's birthday
|
||||
collected on the enrollment form. Previously the field was collected by
|
||||
the UI and accepted by the loyalty service signature, but never written
|
||||
anywhere — see Phase 1.4 of the loyalty production launch plan.
|
||||
|
||||
Revision ID: customers_003
|
||||
Revises: customers_002
|
||||
Create Date: 2026-04-09
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "customers_003"
|
||||
down_revision = "customers_002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"customers",
|
||||
sa.Column("birth_date", sa.Date(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("customers", "birth_date")
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
@@ -17,10 +18,10 @@ from sqlalchemy import (
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class Customer(Base, TimestampMixin):
|
||||
class Customer(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""Customer model with store isolation."""
|
||||
|
||||
__tablename__ = "customers"
|
||||
@@ -34,6 +35,7 @@ class Customer(Base, TimestampMixin):
|
||||
first_name = Column(String(100))
|
||||
last_name = Column(String(100))
|
||||
phone = Column(String(50))
|
||||
birth_date = Column(Date, nullable=True)
|
||||
customer_number = Column(
|
||||
String(100), nullable=False, index=True
|
||||
) # Store-specific ID
|
||||
|
||||
@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
|
||||
},
|
||||
)
|
||||
|
||||
# Collect dashboard cards from enabled modules via widget protocol
|
||||
from app.modules.core.services.widget_aggregator import widget_aggregator
|
||||
|
||||
store = getattr(request.state, "store", None)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
dashboard_cards = []
|
||||
if store and platform:
|
||||
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
|
||||
db,
|
||||
store_id=store.id,
|
||||
customer_id=current_customer.id,
|
||||
platform_id=platform.id,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"customers/storefront/dashboard.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
get_storefront_context(
|
||||
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Provides schemas for:
|
||||
- Admin customer management
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
@@ -60,6 +60,9 @@ class CustomerUpdate(BaseModel):
|
||||
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||
last_name: str | None = Field(None, min_length=1, max_length=100)
|
||||
phone: str | None = Field(None, max_length=50)
|
||||
birth_date: date | None = Field(
|
||||
None, description="Date of birth (YYYY-MM-DD)"
|
||||
)
|
||||
marketing_consent: bool | None = None
|
||||
preferred_language: str | None = Field(
|
||||
None, description="Preferred language (en, fr, de, lb)"
|
||||
@@ -71,6 +74,21 @@ class CustomerUpdate(BaseModel):
|
||||
"""Convert email to lowercase."""
|
||||
return v.lower() if v else None
|
||||
|
||||
@field_validator("birth_date")
|
||||
@classmethod
|
||||
def birth_date_sane(cls, v: date | None) -> date | None:
|
||||
"""Birthday must be in the past and within a plausible age range."""
|
||||
if v is None:
|
||||
return v
|
||||
today = date.today()
|
||||
if v >= today:
|
||||
raise ValueError("birth_date must be in the past")
|
||||
# Plausible human age range — guards against typos like 0001-01-01
|
||||
years = (today - v).days / 365.25
|
||||
if years < 13 or years > 120:
|
||||
raise ValueError("birth_date implies an implausible age")
|
||||
return v
|
||||
|
||||
|
||||
class CustomerPasswordChange(BaseModel):
|
||||
"""Schema for customer password change."""
|
||||
@@ -108,6 +126,7 @@ class CustomerResponse(BaseModel):
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
phone: str | None
|
||||
birth_date: date | None = None
|
||||
customer_number: str
|
||||
marketing_consent: bool
|
||||
preferred_language: str | None
|
||||
@@ -253,6 +272,7 @@ class CustomerDetailResponse(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
phone: str | None = None
|
||||
birth_date: date | None = None
|
||||
customer_number: str | None = None
|
||||
marketing_consent: bool | None = None
|
||||
preferred_language: str | None = None
|
||||
@@ -304,6 +324,7 @@ class AdminCustomerItem(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
phone: str | None = None
|
||||
birth_date: date | None = None
|
||||
customer_number: str
|
||||
marketing_consent: bool = False
|
||||
preferred_language: str | None = None
|
||||
|
||||
@@ -7,7 +7,7 @@ with complete store isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
@@ -567,6 +567,7 @@ class CustomerService:
|
||||
first_name: str = "",
|
||||
last_name: str = "",
|
||||
phone: str | None = None,
|
||||
birth_date: date | None = None,
|
||||
) -> Customer:
|
||||
"""
|
||||
Create a customer for loyalty/external enrollment.
|
||||
@@ -580,6 +581,7 @@ class CustomerService:
|
||||
first_name: First name
|
||||
last_name: Last name
|
||||
phone: Phone number
|
||||
birth_date: Date of birth (optional)
|
||||
|
||||
Returns:
|
||||
Created Customer object
|
||||
@@ -603,6 +605,7 @@ class CustomerService:
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
phone=phone,
|
||||
birth_date=birth_date,
|
||||
hashed_password=unusable_hash,
|
||||
customer_number=cust_number,
|
||||
store_id=store_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user