Compare commits
151 Commits
540205402f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 56c94ac2f4 | |||
| 255ac6525e | |||
| 10e37e749b | |||
| f23990a4d9 | |||
| 62b83b46a4 | |||
| f8b2429533 | |||
| 3883927be0 | |||
| 39e02f0d9b | |||
| 29593f4c61 | |||
| 220f7e3a08 | |||
| 258aa6a34b | |||
| 51bcc9f874 | |||
| eafa086c73 | |||
| ab2daf99bd | |||
| 1cf9fea40a | |||
| cd4f83f2cb | |||
| 457350908a | |||
| e759282116 | |||
| 1df1b2bfca | |||
| 51a2114e02 | |||
| 21e4ac5124 | |||
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e | |||
| adc36246b8 | |||
| dd9dc04328 | |||
| 4a60d75a13 | |||
| e98eddc168 | |||
| 8cd09f3f89 | |||
| 4c1608f78a | |||
| 24219e4d9a | |||
| fde58bea06 | |||
| 52b78ce346 | |||
| f804ff8442 | |||
| d9abb275a5 | |||
| 4b56eb7ab1 | |||
| 27ac7f3e28 | |||
| dfd42c1b10 | |||
| 297b8a8d5a | |||
| 91fb4b0757 | |||
| f4386e97ee | |||
| e8c9fc7e7d | |||
| d591200df8 | |||
| 83af32eb88 | |||
| 2a49e3d30f | |||
| 6e40e16017 | |||
| dd09bcaeec | |||
| 013eafd775 | |||
| 07cd66a0e3 | |||
| 73d453d78a | |||
| d4e9fed719 | |||
| 3e93f64c6b | |||
| 377d2d3ae8 | |||
| b51f9e8e30 | |||
| d380437594 | |||
| cff0af31be | |||
| e492e5f71c | |||
| 9a5b7dd061 | |||
| b3051b423a | |||
| bc951a36d9 | |||
| 2e043260eb | |||
| 1828ac85eb | |||
| 50a4fc38a7 | |||
| 30f3dae5a3 | |||
| 4c750f0268 | |||
| 59b0d8977a | |||
| 2bc03ed97c | |||
| 91963f3b87 | |||
| 3ae0b579d3 | |||
| 972ee1e5d0 | |||
| 70f2803dd3 | |||
| a247622d23 | |||
| 50d50fcbd0 | |||
| b306a5e8f4 | |||
| 28b08580c8 | |||
| 754bfca87d | |||
| 1decb4572c | |||
| d685341b04 | |||
| 0c6d8409c7 | |||
| f81851445e | |||
| 4748368809 | |||
| f310363f7c | |||
| 95f0eac079 | |||
| 11dcfdad73 | |||
| 01f7add8dd | |||
| 0d1007282a | |||
| 2a15c14ee8 | |||
| bc5e227d81 | |||
| 8a70259445 | |||
| 823935c016 | |||
| dab5560de8 | |||
| 157b4c6ec3 | |||
| 211c46ebbc | |||
| d81e9a3fa4 | |||
| fd0de714a4 | |||
| c6b155520c | |||
| 66b77e747d | |||
| 71b5eb1758 | |||
| b4f01210d9 | |||
| 9bceeaac9c | |||
| 332960de30 | |||
| 0455e63a2e | |||
| aaed1b2d01 | |||
| 9dee534b2f | |||
| beef3ce76b | |||
| 884a694718 | |||
| 4cafbe9610 | |||
| 19923ed26b | |||
| 46f8d227b8 | |||
| 95e4956216 | |||
| 77e520bbce | |||
| 518bace534 | |||
| fcde2d68fc | |||
| 5a33f68743 | |||
| 040cbd1962 | |||
| b679c9687d | |||
| 314360a394 | |||
| 44a0c38016 | |||
| da9e1ab293 | |||
| 5de297a804 | |||
| 4429674100 | |||
| 316ec42566 | |||
| 894832c62b | |||
| 1d90bfe044 | |||
| ce0caa5685 | |||
| 33f823aba0 | |||
| edd55cd2fd | |||
| f3344b2859 | |||
| 1107de989b | |||
| a423bcf03e | |||
| 661547f6cf | |||
| 3015a490f9 | |||
| 5b4ed79f87 | |||
| 52a5f941fe | |||
| 6161d69ba2 | |||
| f41f72b86f | |||
| 644bf158cd | |||
| f89c0382f0 | |||
| 11b8e31a29 | |||
| 0ddef13124 | |||
| 60bed05d3f | |||
| 40da2d6b11 | |||
| d96e0ea1b4 | |||
| 7d652716bb | |||
| b6047f5b7d | |||
| 366d4b9765 |
10
.env.example
10
.env.example
@@ -72,6 +72,11 @@ LOG_FILE=logs/app.log
|
|||||||
# Your main platform domain
|
# Your main platform domain
|
||||||
MAIN_DOMAIN=wizard.lu
|
MAIN_DOMAIN=wizard.lu
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, billing redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
APP_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
# Enable/disable custom domains
|
# Enable/disable custom domains
|
||||||
ALLOW_CUSTOM_DOMAINS=True
|
ALLOW_CUSTOM_DOMAINS=True
|
||||||
@@ -223,7 +228,10 @@ R2_BACKUP_BUCKET=orion-backups
|
|||||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||||
# Get Issuer ID from https://pay.google.com/business/console
|
# Get Issuer ID from https://pay.google.com/business/console
|
||||||
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||||
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
|
||||||
|
# Path is validated at startup — file must exist and be readable, otherwise
|
||||||
|
# the app fails fast at import time.
|
||||||
|
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
|
||||||
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||||
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ jobs:
|
|||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests — unit only (integration tests run locally via make test)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 150
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -55,10 +56,9 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# act_runner executes jobs in Docker containers on the same network as services,
|
|
||||||
# so use the service name (postgres) as hostname with the internal port (5432)
|
|
||||||
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
TEST_DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/orion_test"
|
||||||
|
LOG_LEVEL: "WARNING"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -73,8 +73,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
run: uv pip install --system -r requirements.txt -r requirements-test.txt
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: python -m pytest -v --tb=short
|
run: python -m pytest -m "unit" -q --tb=short --timeout=120 --no-cov --override-ini="addopts=" -p no:cacheprovider -p no:logging --durations=20
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -156,11 +156,10 @@ uploads/
|
|||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
|
||||||
.dockerignore.local
|
.dockerignore.local
|
||||||
*.override.yml
|
|
||||||
|
|
||||||
# Deployment & Security
|
# Deployment & Security
|
||||||
|
.build-info
|
||||||
deployment-local/
|
deployment-local/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
|||||||
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
118
alembic/versions/softdelete_001_add_soft_delete.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Add soft delete columns (deleted_at, deleted_by_id) to business-critical tables.
|
||||||
|
|
||||||
|
Also converts unique constraints on users.email, users.username,
|
||||||
|
stores.store_code, stores.subdomain to partial unique indexes
|
||||||
|
that only apply to non-deleted rows.
|
||||||
|
|
||||||
|
Revision ID: softdelete_001
|
||||||
|
Revises: remove_is_primary_001, customers_002, dev_tools_002, orders_002, tenancy_004
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "softdelete_001"
|
||||||
|
down_revision = (
|
||||||
|
"remove_is_primary_001",
|
||||||
|
"customers_002",
|
||||||
|
"dev_tools_002",
|
||||||
|
"orders_002",
|
||||||
|
"tenancy_004",
|
||||||
|
)
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables receiving soft-delete columns
|
||||||
|
SOFT_DELETE_TABLES = [
|
||||||
|
"users",
|
||||||
|
"merchants",
|
||||||
|
"stores",
|
||||||
|
"customers",
|
||||||
|
"store_users",
|
||||||
|
"orders",
|
||||||
|
"products",
|
||||||
|
"loyalty_programs",
|
||||||
|
"loyalty_cards",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ======================================================================
|
||||||
|
# Step 1: Add deleted_at and deleted_by_id to all soft-delete tables
|
||||||
|
# ======================================================================
|
||||||
|
for table in SOFT_DELETE_TABLES:
|
||||||
|
op.add_column(table, sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column(
|
||||||
|
"deleted_by_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_{table}_deleted_at", table, ["deleted_at"])
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Step 2: Replace simple unique constraints with partial unique indexes
|
||||||
|
# (only enforce uniqueness among non-deleted rows)
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
# users.email: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_email_active ON users (email) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
# Keep a non-unique index for lookups on all rows (including deleted)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# users.username: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_users_username_active ON users (username) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"])
|
||||||
|
|
||||||
|
# stores.store_code: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_store_code_active ON stores (store_code) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"])
|
||||||
|
|
||||||
|
# stores.subdomain: drop old unique index, create partial
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute(
|
||||||
|
'CREATE UNIQUE INDEX uq_stores_subdomain_active ON stores (subdomain) '
|
||||||
|
'WHERE deleted_at IS NULL'
|
||||||
|
)
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse partial unique indexes back to simple unique indexes
|
||||||
|
op.drop_index("ix_stores_subdomain", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_subdomain_active")
|
||||||
|
op.create_index("ix_stores_subdomain", "stores", ["subdomain"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_stores_store_code", table_name="stores")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_stores_store_code_active")
|
||||||
|
op.create_index("ix_stores_store_code", "stores", ["store_code"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_username_active")
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_email_active")
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
# Remove soft-delete columns from all tables
|
||||||
|
for table in reversed(SOFT_DELETE_TABLES):
|
||||||
|
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||||
|
op.drop_column(table, "deleted_by_id")
|
||||||
|
op.drop_column(table, "deleted_at")
|
||||||
@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Invalid token, store mismatch, or other error
|
# Invalid token, store mismatch, or other error
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STOREFRONT MODULE GATING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def make_storefront_module_gate(module_code: str):
|
||||||
|
"""
|
||||||
|
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||||
|
|
||||||
|
Used by main.py at route registration time: each non-core module's storefront
|
||||||
|
router gets this dependency injected automatically. The framework already knows
|
||||||
|
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A FastAPI dependency function
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_module_enabled(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
from app.modules.service import module_service
|
||||||
|
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
if not platform:
|
||||||
|
return # No platform context — let other middleware handle it
|
||||||
|
|
||||||
|
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||||
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
|
return _check_module_enabled
|
||||||
|
|||||||
60
app/core/build_info.py
Normal file
60
app/core/build_info.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# app/core/build_info.py
|
||||||
|
"""
|
||||||
|
Build information utilities.
|
||||||
|
|
||||||
|
Reads commit SHA and deploy timestamp from .build-info file
|
||||||
|
(written by scripts/deploy.sh at deploy time), or falls back
|
||||||
|
to git for local development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUILD_INFO_FILE = Path(__file__).resolve().parent.parent.parent / ".build-info"
|
||||||
|
_cached_info: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_info() -> dict:
|
||||||
|
"""Return build info: commit, deployed_at, environment."""
|
||||||
|
global _cached_info
|
||||||
|
if _cached_info is not None:
|
||||||
|
return _cached_info
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"commit": None,
|
||||||
|
"deployed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try .build-info file first (written by deploy.sh)
|
||||||
|
if _BUILD_INFO_FILE.is_file():
|
||||||
|
try:
|
||||||
|
data = json.loads(_BUILD_INFO_FILE.read_text())
|
||||||
|
info["commit"] = data.get("commit")
|
||||||
|
info["deployed_at"] = data.get("deployed_at")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read .build-info: {e}")
|
||||||
|
|
||||||
|
# Fall back to git for local development
|
||||||
|
if not info["commit"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short=8", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info["commit"] = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not info["deployed_at"]:
|
||||||
|
info["deployed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
_cached_info = info
|
||||||
|
return info
|
||||||
@@ -98,6 +98,11 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
main_domain: str = "wizard.lu"
|
main_domain: str = "wizard.lu"
|
||||||
|
|
||||||
|
# Full base URL for outbound links (emails, redirects, etc.)
|
||||||
|
# Must include protocol and port if non-standard.
|
||||||
|
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||||
|
app_base_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
# Custom domain features
|
# Custom domain features
|
||||||
allow_custom_domains: bool = True
|
allow_custom_domains: bool = True
|
||||||
require_domain_verification: bool = True
|
require_domain_verification: bool = True
|
||||||
@@ -217,14 +222,6 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# GOOGLE WALLET (LOYALTY MODULE)
|
|
||||||
# =============================================================================
|
|
||||||
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)
|
# APPLE WALLET (LOYALTY MODULE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -234,7 +231,7 @@ class Settings(BaseSettings):
|
|||||||
loyalty_apple_signer_cert_path: str | None = None
|
loyalty_apple_signer_cert_path: str | None = None
|
||||||
loyalty_apple_signer_key_path: str | None = None
|
loyalty_apple_signer_key_path: str | None = None
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Singleton settings instance
|
# Singleton settings instance
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ Note: This project uses PostgreSQL only. SQLite is not supported.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker, with_loader_criteria
|
||||||
from sqlalchemy.pool import QueuePool
|
from sqlalchemy.pool import QueuePool
|
||||||
|
|
||||||
from .config import settings, validate_database_url
|
from .config import settings, validate_database_url
|
||||||
@@ -38,6 +38,45 @@ Base = declarative_base()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Soft-delete automatic query filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Any model that inherits SoftDeleteMixin will automatically have
|
||||||
|
# `WHERE deleted_at IS NULL` appended to SELECT queries.
|
||||||
|
# Bypass with: db.execute(stmt, execution_options={"include_deleted": True})
|
||||||
|
# or db.query(Model).execution_options(include_deleted=True).all()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register_soft_delete_filter(session_factory):
|
||||||
|
"""Register the soft-delete query filter on a session factory.
|
||||||
|
|
||||||
|
Call this for any sessionmaker that should auto-exclude soft-deleted records.
|
||||||
|
Used for both the production SessionLocal and test session factories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@event.listens_for(session_factory, "do_orm_execute")
|
||||||
|
def _soft_delete_filter(orm_execute_state):
|
||||||
|
if (
|
||||||
|
orm_execute_state.is_select
|
||||||
|
and not orm_execute_state.execution_options.get("include_deleted", False)
|
||||||
|
):
|
||||||
|
from models.database.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
orm_execute_state.statement = orm_execute_state.statement.options(
|
||||||
|
with_loader_criteria(
|
||||||
|
SoftDeleteMixin,
|
||||||
|
lambda cls: cls.deleted_at.is_(None),
|
||||||
|
include_aliases=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _soft_delete_filter
|
||||||
|
|
||||||
|
|
||||||
|
# Register on the production session factory
|
||||||
|
register_soft_delete_filter(SessionLocal)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
Database session dependency for FastAPI routes.
|
Database session dependency for FastAPI routes.
|
||||||
|
|||||||
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
|
||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "ANALYTICS_"}
|
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class MenuItemDefinition:
|
|||||||
requires_permission: str | None = None
|
requires_permission: str | None = None
|
||||||
badge_source: str | None = None
|
badge_source: str | None = None
|
||||||
is_super_admin_only: bool = False
|
is_super_admin_only: bool = False
|
||||||
|
header_template: str | None = None # Optional partial for custom header rendering
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "BILLING_"}
|
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
|||||||
store = billing_service.get_store(db, store_id)
|
store = billing_service.get_store(db, store_id)
|
||||||
|
|
||||||
# Build URLs
|
# Build URLs
|
||||||
base_url = f"https://{settings.main_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
||||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def create_checkout_session(
|
|||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
|
|
||||||
base_url = f"https://{settings.main_domain}"
|
base_url = settings.app_base_url.rstrip("/")
|
||||||
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
||||||
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||||
|
|
||||||
@@ -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)
|
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||||
|
|
||||||
store_code = subscription_service.get_store_code(db, store_id)
|
store_code = subscription_service.get_store_code(db, store_id)
|
||||||
return_url = f"https://{settings.main_domain}/store/{store_code}/billing"
|
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
|
||||||
|
|
||||||
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
||||||
|
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ class SignupService:
|
|||||||
|
|
||||||
# Build login URL
|
# Build login URL
|
||||||
login_url = (
|
login_url = (
|
||||||
f"https://{settings.main_domain}"
|
f"{settings.app_base_url.rstrip('/')}"
|
||||||
f"/store/{store.store_code}/dashboard"
|
f"/store/{store.store_code}/dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="cart",
|
id="cart",
|
||||||
label_key="storefront.actions.cart",
|
label_key="cart.storefront.actions.cart",
|
||||||
icon="shopping-cart",
|
icon="shopping-cart",
|
||||||
route="cart",
|
route="cart",
|
||||||
order=20,
|
order=20,
|
||||||
|
header_template="cart/storefront/partials/header-cart.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Warenkörbe der Kunden anzeigen",
|
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||||
"manage": "Warenkörbe verwalten",
|
"manage": "Warenkörbe verwalten",
|
||||||
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Warenkorb"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"product_not_available": "Product not available",
|
"product_not_available": "Product not available",
|
||||||
"error_adding": "Error adding item to cart",
|
"error_adding": "Error adding item to cart",
|
||||||
"error_updating": "Error updating cart"
|
"error_updating": "Error updating cart"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Cart"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Voir les paniers des clients",
|
"view_desc": "Voir les paniers des clients",
|
||||||
"manage": "Gérer les paniers",
|
"manage": "Gérer les paniers",
|
||||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Panier"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,5 +44,10 @@
|
|||||||
"view_desc": "Clientekuerf kucken",
|
"view_desc": "Clientekuerf kucken",
|
||||||
"manage": "Kuerf verwalten",
|
"manage": "Kuerf verwalten",
|
||||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"actions": {
|
||||||
|
"cart": "Kuerf"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Cart page initializing...');
|
console.log('[STOREFRONT] Cart page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
@@ -223,17 +223,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
|
console.log(`[STOREFRONT] Loading cart for session ${this.sessionId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
this.cartCount = this.totalItems;
|
this.cartCount = this.totalItems;
|
||||||
console.log('[SHOP] Cart loaded:', this.items.length, 'items');
|
console.log('[STOREFRONT] Cart loaded:', this.items.length, 'items');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load cart:', error);
|
console.error('[STOREFRONT] Failed to load cart:', error);
|
||||||
this.showToast('Failed to load cart', 'error');
|
this.showToast('Failed to load cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -249,7 +249,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Updating quantity:', productId, newQuantity);
|
console.log('[STOREFRONT] Updating quantity:', productId, newQuantity);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -268,7 +268,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to update quantity');
|
throw new Error('Failed to update quantity');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Update quantity error:', error);
|
console.error('[STOREFRONT] Update quantity error:', error);
|
||||||
this.showToast('Failed to update quantity', 'error');
|
this.showToast('Failed to update quantity', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.updating = true;
|
this.updating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing item:', productId);
|
console.log('[STOREFRONT] Removing item:', productId);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove item');
|
throw new Error('Failed to remove item');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Remove item error:', error);
|
console.error('[STOREFRONT] Remove item error:', error);
|
||||||
this.showToast('Failed to remove item', 'error');
|
this.showToast('Failed to remove item', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.updating = false;
|
this.updating = false;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{# cart/storefront/partials/header-cart.html #}
|
||||||
|
{# Cart icon with badge for storefront header — provided by cart module #}
|
||||||
|
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||||
|
<span x-show="cartCount > 0"
|
||||||
|
x-text="cartCount"
|
||||||
|
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||||
|
style="background-color: var(--color-accent)">
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="products",
|
id="products",
|
||||||
label_key="storefront.nav.products",
|
label_key="catalog.storefront.nav.products",
|
||||||
icon="shopping-bag",
|
icon="shopping-bag",
|
||||||
route="products",
|
route="products",
|
||||||
order=10,
|
order=10,
|
||||||
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="search",
|
id="search",
|
||||||
label_key="storefront.actions.search",
|
label_key="catalog.storefront.actions.search",
|
||||||
icon="search",
|
icon="search",
|
||||||
route="",
|
route="",
|
||||||
order=10,
|
order=10,
|
||||||
|
header_template="catalog/storefront/partials/header-search.html",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport von Produkten",
|
"products_import_desc": "Massenimport von Produkten",
|
||||||
"products_export": "Produkte exportieren",
|
"products_export": "Produkte exportieren",
|
||||||
"products_export_desc": "Produktdaten exportieren"
|
"products_export_desc": "Produktdaten exportieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkte"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Suchen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,13 @@
|
|||||||
"menu": {
|
"menu": {
|
||||||
"products_inventory": "Products & Inventory",
|
"products_inventory": "Products & Inventory",
|
||||||
"all_products": "All Products"
|
"all_products": "All Products"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Products"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Search"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Importation en masse de produits",
|
"products_import_desc": "Importation en masse de produits",
|
||||||
"products_export": "Exporter les produits",
|
"products_export": "Exporter les produits",
|
||||||
"products_export_desc": "Exporter les données produits"
|
"products_export_desc": "Exporter les données produits"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produits"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Rechercher"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,5 +89,13 @@
|
|||||||
"products_import_desc": "Massenimport vu Produiten",
|
"products_import_desc": "Massenimport vu Produiten",
|
||||||
"products_export": "Produiten exportéieren",
|
"products_export": "Produiten exportéieren",
|
||||||
"products_export_desc": "Produitdaten exportéieren"
|
"products_export_desc": "Produitdaten exportéieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"nav": {
|
||||||
|
"products": "Produkter"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"search": "Sichen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ from sqlalchemy.orm import relationship
|
|||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from app.utils.money import cents_to_euros, euros_to_cents
|
from app.utils.money import cents_to_euros, euros_to_cents
|
||||||
from models.database.base import TimestampMixin
|
from models.database.base import SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Product(Base, TimestampMixin):
|
class Product(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
"""Store-specific product.
|
"""Store-specific product.
|
||||||
|
|
||||||
Products can be created from marketplace imports or directly by stores.
|
Products can be created from marketplace imports or directly by stores.
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ router = APIRouter()
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Render shop homepage / product catalog.
|
Render product catalog listing.
|
||||||
Shows featured products and categories.
|
Shows featured products and categories.
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -192,9 +192,11 @@ class ProductService:
|
|||||||
True if deleted
|
True if deleted
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from app.core.soft_delete import soft_delete
|
||||||
|
|
||||||
product = self.get_product(db, store_id, product_id)
|
product = self.get_product(db, store_id, product_id)
|
||||||
|
|
||||||
db.delete(product)
|
soft_delete(db, product, deleted_by_id=None)
|
||||||
|
|
||||||
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
logger.info(f"Deleted product {product_id} from store {store_id} catalog")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Category page initializing...');
|
console.log('[STOREFRONT] Category page initializing...');
|
||||||
console.log('[SHOP] Category slug:', this.categorySlug);
|
console.log('[STOREFRONT] Category slug:', this.categorySlug);
|
||||||
|
|
||||||
// Convert slug to display name
|
// Convert slug to display name
|
||||||
this.categoryName = this.categorySlug
|
this.categoryName = this.categorySlug
|
||||||
@@ -213,7 +213,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
params.append('sort', this.sortBy);
|
params.append('sort', this.sortBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
|
console.log(`[STOREFRONT] Loading category products from /api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
@@ -223,12 +223,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load category products:', error);
|
console.error('[STOREFRONT] Failed to load category products:', error);
|
||||||
this.showToast('Failed to load products', 'error');
|
this.showToast('Failed to load products', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -243,7 +243,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -262,16 +262,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{# catalog/storefront/partials/header-search.html #}
|
||||||
|
{# Search button for storefront header — provided by catalog module #}
|
||||||
|
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
|
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
|
||||||
|
</button>
|
||||||
@@ -256,16 +256,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Product detail page initializing...');
|
console.log('[STOREFRONT] Product detail page initializing...');
|
||||||
|
|
||||||
// Call parent init to set up sessionId
|
// Call parent init to set up sessionId
|
||||||
if (baseData.init) {
|
if (baseData.init) {
|
||||||
baseData.init.call(this);
|
baseData.init.call(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[SHOP] Product ID:', this.productId);
|
console.log('[STOREFRONT] Product ID:', this.productId);
|
||||||
console.log('[SHOP] Store ID:', this.storeId);
|
console.log('[STOREFRONT] Store ID:', this.storeId);
|
||||||
console.log('[SHOP] Session ID:', this.sessionId);
|
console.log('[STOREFRONT] Session ID:', this.sessionId);
|
||||||
|
|
||||||
await this.loadProduct();
|
await this.loadProduct();
|
||||||
},
|
},
|
||||||
@@ -275,7 +275,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[SHOP] Loading product ${this.productId}...`);
|
console.log(`[STOREFRONT] Loading product ${this.productId}...`);
|
||||||
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -283,7 +283,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.product = await response.json();
|
this.product = await response.json();
|
||||||
console.log('[SHOP] Product loaded:', this.product);
|
console.log('[STOREFRONT] Product loaded:', this.product);
|
||||||
|
|
||||||
// Set default image
|
// Set default image
|
||||||
if (this.product?.marketplace_product?.image_link) {
|
if (this.product?.marketplace_product?.image_link) {
|
||||||
@@ -297,7 +297,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
await this.loadRelatedProducts();
|
await this.loadRelatedProducts();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load product:', error);
|
console.error('[STOREFRONT] Failed to load product:', error);
|
||||||
this.showToast('Failed to load product', 'error');
|
this.showToast('Failed to load product', 'error');
|
||||||
// Redirect back to products after error
|
// Redirect back to products after error
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -320,10 +320,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
.filter(p => p.id !== parseInt(this.productId))
|
.filter(p => p.id !== parseInt(this.productId))
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
|
|
||||||
console.log('[SHOP] Loaded related products:', this.relatedProducts.length);
|
console.log('[STOREFRONT] Loaded related products:', this.relatedProducts.length);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load related products:', error);
|
console.error('[STOREFRONT] Failed to load related products:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// Add to cart
|
// Add to cart
|
||||||
async addToCart() {
|
async addToCart() {
|
||||||
if (!this.canAddToCart) {
|
if (!this.canAddToCart) {
|
||||||
console.warn('[SHOP] Cannot add to cart:', {
|
console.warn('[STOREFRONT] Cannot add to cart:', {
|
||||||
canAddToCart: this.canAddToCart,
|
canAddToCart: this.canAddToCart,
|
||||||
isActive: this.product?.is_active,
|
isActive: this.product?.is_active,
|
||||||
inventory: this.product?.available_inventory,
|
inventory: this.product?.available_inventory,
|
||||||
@@ -374,7 +374,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
quantity: this.quantity
|
quantity: this.quantity
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[SHOP] Adding to cart:', {
|
console.log('[STOREFRONT] Adding to cart:', {
|
||||||
url,
|
url,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
productId: this.productId,
|
productId: this.productId,
|
||||||
@@ -390,14 +390,14 @@ document.addEventListener('alpine:init', () => {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[SHOP] Add to cart response:', {
|
console.log('[STOREFRONT] Add to cart response:', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
ok: response.ok
|
ok: response.ok
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
|
|
||||||
this.cartCount += this.quantity;
|
this.cartCount += this.quantity;
|
||||||
this.showToast(
|
this.showToast(
|
||||||
@@ -409,11 +409,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.quantity = this.product?.min_quantity || 1;
|
this.quantity = this.product?.min_quantity || 1;
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error response:', error);
|
console.error('[STOREFRONT] Add to cart error response:', error);
|
||||||
throw new Error(error.detail || 'Failed to add to cart');
|
throw new Error(error.detail || 'Failed to add to cart');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.addingToCart = false;
|
this.addingToCart = false;
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Products page initializing...');
|
console.log('[STOREFRONT] Products page initializing...');
|
||||||
await this.loadProducts();
|
await this.loadProducts();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
params.append('search', this.filters.search);
|
params.append('search', this.filters.search);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
|
console.log(`[STOREFRONT] Loading products from /api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||||
|
|
||||||
@@ -188,12 +188,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.products.length} products (total: ${data.total})`);
|
console.log(`[STOREFRONT] Loaded ${data.products.length} products (total: ${data.total})`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.pagination.total = data.total;
|
this.pagination.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load products:', error);
|
console.error('[STOREFRONT] Failed to load products:', error);
|
||||||
this.showToast('Failed to load products', 'error');
|
this.showToast('Failed to load products', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
// formatPrice is inherited from storefrontLayoutData() via spread operator
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -227,16 +227,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product.title} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Search page initializing...');
|
console.log('[STOREFRONT] Search page initializing...');
|
||||||
|
|
||||||
// Check for query parameter in URL
|
// Check for query parameter in URL
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -254,7 +254,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
limit: this.perPage
|
limit: this.perPage
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
|
console.log(`[STOREFRONT] Searching: /api/v1/storefront/products/search?${params}`);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
||||||
|
|
||||||
@@ -264,12 +264,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Search found ${data.total} results`);
|
console.log(`[STOREFRONT] Search found ${data.total} results`);
|
||||||
|
|
||||||
this.products = data.products;
|
this.products = data.products;
|
||||||
this.total = data.total;
|
this.total = data.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Search failed:', error);
|
console.error('[STOREFRONT] Search failed:', error);
|
||||||
this.showToast('Search failed. Please try again.', 'error');
|
this.showToast('Search failed. Please try again.', 'error');
|
||||||
this.products = [];
|
this.products = [];
|
||||||
this.total = 0;
|
this.total = 0;
|
||||||
@@ -289,7 +289,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -308,16 +308,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('[SHOP] Wishlist page initializing...');
|
console.log('[STOREFRONT] Wishlist page initializing...');
|
||||||
|
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
this.isLoggedIn = await this.checkLoginStatus();
|
this.isLoggedIn = await this.checkLoginStatus();
|
||||||
@@ -168,7 +168,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Loading wishlist...');
|
console.log('[STOREFRONT] Loading wishlist...');
|
||||||
|
|
||||||
const response = await fetch('/api/v1/storefront/wishlist');
|
const response = await fetch('/api/v1/storefront/wishlist');
|
||||||
|
|
||||||
@@ -182,11 +182,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`[SHOP] Loaded ${data.items?.length || 0} wishlist items`);
|
console.log(`[STOREFRONT] Loaded ${data.items?.length || 0} wishlist items`);
|
||||||
|
|
||||||
this.items = data.items || [];
|
this.items = data.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to load wishlist:', error);
|
console.error('[STOREFRONT] Failed to load wishlist:', error);
|
||||||
this.showToast('Failed to load wishlist', 'error');
|
this.showToast('Failed to load wishlist', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -195,7 +195,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
async removeFromWishlist(item) {
|
async removeFromWishlist(item) {
|
||||||
try {
|
try {
|
||||||
console.log('[SHOP] Removing from wishlist:', item);
|
console.log('[STOREFRONT] Removing from wishlist:', item);
|
||||||
|
|
||||||
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -208,13 +208,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
throw new Error('Failed to remove from wishlist');
|
throw new Error('Failed to remove from wishlist');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Failed to remove from wishlist:', error);
|
console.error('[STOREFRONT] Failed to remove from wishlist:', error);
|
||||||
this.showToast('Failed to remove from wishlist', 'error');
|
this.showToast('Failed to remove from wishlist', 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async addToCart(product) {
|
async addToCart(product) {
|
||||||
console.log('[SHOP] Adding to cart:', product);
|
console.log('[STOREFRONT] Adding to cart:', product);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||||
@@ -233,16 +233,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('[SHOP] Add to cart success:', result);
|
console.log('[STOREFRONT] Add to cart success:', result);
|
||||||
this.cartCount += 1;
|
this.cartCount += 1;
|
||||||
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
this.showToast(`${product.marketplace_product?.title || 'Product'} added to cart`, 'success');
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
console.error('[SHOP] Add to cart error:', error);
|
console.error('[STOREFRONT] Add to cart error:', error);
|
||||||
this.showToast(error.message || 'Failed to add to cart', 'error');
|
this.showToast(error.message || 'Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SHOP] Add to cart exception:', error);
|
console.error('[STOREFRONT] Add to cart exception:', error);
|
||||||
this.showToast('Failed to add to cart', 'error');
|
this.showToast('Failed to add to cart', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "CMS_"}
|
model_config = {"env_prefix": "CMS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mein Konto",
|
||||||
|
"learn_more": "Mehr erfahren",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"quick_links": "Schnellzugriff",
|
||||||
|
"information": "Informationen",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
"content_pages": "Content Pages",
|
"content_pages": "Content Pages",
|
||||||
"store_themes": "Store Themes",
|
"store_themes": "Store Themes",
|
||||||
"media_library": "Media Library"
|
"media_library": "Media Library"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "My Account",
|
||||||
|
"learn_more": "Learn More",
|
||||||
|
"explore": "Explore",
|
||||||
|
"quick_links": "Quick Links",
|
||||||
|
"information": "Information",
|
||||||
|
"about": "About Us",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mon Compte",
|
||||||
|
"learn_more": "En savoir plus",
|
||||||
|
"explore": "Découvrir",
|
||||||
|
"quick_links": "Liens rapides",
|
||||||
|
"information": "Informations",
|
||||||
|
"about": "À propos",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,5 +388,15 @@
|
|||||||
},
|
},
|
||||||
"confirmations": {
|
"confirmations": {
|
||||||
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"my_account": "Mäi Kont",
|
||||||
|
"learn_more": "Méi gewuer ginn",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"quick_links": "Schnellzougrëff",
|
||||||
|
"information": "Informatiounen",
|
||||||
|
"about": "Iwwer eis",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"faq": "FAQ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add meta_description_translations and drop meta_keywords from content_pages
|
||||||
|
|
||||||
|
Revision ID: cms_003
|
||||||
|
Revises: cms_002
|
||||||
|
Create Date: 2026-04-15
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "cms_003"
|
||||||
|
down_revision = "cms_002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"content_pages",
|
||||||
|
sa.Column(
|
||||||
|
"meta_description_translations",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="Language-keyed meta description dict for multi-language SEO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.drop_column("content_pages", "meta_keywords")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"content_pages",
|
||||||
|
sa.Column("meta_keywords", sa.String(300), nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_column("content_pages", "meta_description_translations")
|
||||||
@@ -135,7 +135,12 @@ class ContentPage(Base):
|
|||||||
|
|
||||||
# SEO
|
# SEO
|
||||||
meta_description = Column(String(300), nullable=True)
|
meta_description = Column(String(300), nullable=True)
|
||||||
meta_keywords = Column(String(300), nullable=True)
|
meta_description_translations = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
comment="Language-keyed meta description dict for multi-language SEO",
|
||||||
|
)
|
||||||
|
|
||||||
# Publishing
|
# Publishing
|
||||||
is_published = Column(Boolean, default=False, nullable=False)
|
is_published = Column(Boolean, default=False, nullable=False)
|
||||||
@@ -230,6 +235,16 @@ class ContentPage(Base):
|
|||||||
)
|
)
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
|
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
|
||||||
|
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
|
||||||
|
if self.meta_description_translations:
|
||||||
|
return (
|
||||||
|
self.meta_description_translations.get(lang)
|
||||||
|
or self.meta_description_translations.get(default_lang)
|
||||||
|
or self.meta_description or ""
|
||||||
|
)
|
||||||
|
return self.meta_description or ""
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert to dictionary for API responses."""
|
"""Convert to dictionary for API responses."""
|
||||||
return {
|
return {
|
||||||
@@ -248,7 +263,7 @@ class ContentPage(Base):
|
|||||||
"template": self.template,
|
"template": self.template,
|
||||||
"sections": self.sections,
|
"sections": self.sections,
|
||||||
"meta_description": self.meta_description,
|
"meta_description": self.meta_description,
|
||||||
"meta_keywords": self.meta_keywords,
|
"meta_description_translations": self.meta_description_translations,
|
||||||
"is_published": self.is_published,
|
"is_published": self.is_published,
|
||||||
"published_at": (
|
"published_at": (
|
||||||
self.published_at.isoformat() if self.published_at else None
|
self.published_at.isoformat() if self.published_at else None
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def create_platform_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=page_data.meta_description_translations,
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -117,7 +117,7 @@ def create_store_page(
|
|||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=page_data.meta_description_translations,
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -177,11 +177,13 @@ def update_page(
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=page_data.title,
|
title=page_data.title,
|
||||||
|
title_translations=page_data.title_translations,
|
||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
|
content_translations=page_data.content_translations,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
template=page_data.template,
|
template=page_data.template,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=page_data.meta_description_translations,
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ def create_store_page(
|
|||||||
store_id=current_user.token_store_id,
|
store_id=current_user.token_store_id,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=getattr(page_data, "meta_description_translations", None),
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
@@ -241,7 +241,7 @@ def update_store_page(
|
|||||||
content=page_data.content,
|
content=page_data.content,
|
||||||
content_format=page_data.content_format,
|
content_format=page_data.content_format,
|
||||||
meta_description=page_data.meta_description,
|
meta_description=page_data.meta_description,
|
||||||
meta_keywords=page_data.meta_keywords,
|
meta_description_translations=getattr(page_data, "meta_description_translations", None),
|
||||||
is_published=page_data.is_published,
|
is_published=page_data.is_published,
|
||||||
show_in_footer=page_data.show_in_footer,
|
show_in_footer=page_data.show_in_footer,
|
||||||
show_in_header=page_data.show_in_header,
|
show_in_header=page_data.show_in_header,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"""
|
"""
|
||||||
CMS Admin Page Routes (HTML rendering).
|
CMS Admin Page Routes (HTML rendering).
|
||||||
|
|
||||||
Admin pages for managing platform and store content pages.
|
Admin pages for managing platform and store content pages,
|
||||||
|
and store theme customization.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Path, Request
|
||||||
@@ -10,6 +11,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_menu_access
|
from app.api.deps import get_db, require_menu_access
|
||||||
|
from app.modules.core.utils.page_context import get_admin_context
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
from app.modules.tenancy.models import User
|
from app.modules.tenancy.models import User
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
@@ -86,3 +88,49 @@ async def admin_content_page_edit(
|
|||||||
"page_id": page_id,
|
"page_id": page_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STORE THEMES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/store-themes", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def admin_store_themes_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(
|
||||||
|
require_menu_access("store-themes", FrontendType.ADMIN)
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render store themes selection page.
|
||||||
|
Allows admins to select a store to customize their theme.
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"cms/admin/store-themes.html",
|
||||||
|
get_admin_context(request, db, current_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stores/{store_code}/theme",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
async def admin_store_theme_page(
|
||||||
|
request: Request,
|
||||||
|
store_code: str = Path(..., description="Store code"),
|
||||||
|
current_user: User = Depends(
|
||||||
|
require_menu_access("store-themes", FrontendType.ADMIN)
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render store theme customization page.
|
||||||
|
Allows admins to customize colors, fonts, layout, and branding.
|
||||||
|
"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"cms/admin/store-theme.html",
|
||||||
|
get_admin_context(request, db, current_user, store_code=store_code),
|
||||||
|
)
|
||||||
|
|||||||
@@ -28,6 +28,79 @@ ROUTE_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STOREFRONT HOMEPAGE
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def storefront_homepage(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Storefront homepage handler.
|
||||||
|
|
||||||
|
Looks for a CMS page with slug="home" (store override → store default),
|
||||||
|
and renders the appropriate landing template. Falls back to the default
|
||||||
|
landing template when no CMS homepage exists.
|
||||||
|
"""
|
||||||
|
store = getattr(request.state, "store", None)
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
store_id = store.id if store else None
|
||||||
|
if not platform:
|
||||||
|
raise HTTPException(status_code=400, detail="Platform context required")
|
||||||
|
|
||||||
|
# Try to load a homepage from CMS (store override → store default)
|
||||||
|
page = content_page_service.get_page_for_store(
|
||||||
|
db,
|
||||||
|
platform_id=platform.id,
|
||||||
|
slug="home",
|
||||||
|
store_id=store_id,
|
||||||
|
include_unpublished=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve placeholders for store default pages (title, content, sections)
|
||||||
|
page_content = None
|
||||||
|
page_title = None
|
||||||
|
page_sections = None
|
||||||
|
if page:
|
||||||
|
page_content = page.content
|
||||||
|
page_title = page.title
|
||||||
|
page_sections = page.sections
|
||||||
|
if page.is_store_default and store:
|
||||||
|
page_content = content_page_service.resolve_placeholders(
|
||||||
|
page.content, store
|
||||||
|
)
|
||||||
|
page_title = content_page_service.resolve_placeholders(
|
||||||
|
page.title, store
|
||||||
|
)
|
||||||
|
if page_sections:
|
||||||
|
page_sections = content_page_service.resolve_placeholders_deep(
|
||||||
|
page_sections, store
|
||||||
|
)
|
||||||
|
|
||||||
|
context = get_storefront_context(request, db=db, page=page)
|
||||||
|
if page_content:
|
||||||
|
context["page_content"] = page_content
|
||||||
|
if page_title:
|
||||||
|
context["page_title"] = page_title
|
||||||
|
if page_sections:
|
||||||
|
context["page_sections"] = page_sections
|
||||||
|
|
||||||
|
# Select template based on page.template field (or default)
|
||||||
|
template_map = {
|
||||||
|
"full": "cms/storefront/landing-full.html",
|
||||||
|
"modern": "cms/storefront/landing-modern.html",
|
||||||
|
"minimal": "cms/storefront/landing-minimal.html",
|
||||||
|
}
|
||||||
|
template_name = "cms/storefront/landing-default.html"
|
||||||
|
if page and page.template:
|
||||||
|
template_name = template_map.get(page.template, template_name)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(template_name, context)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DYNAMIC CONTENT PAGES (CMS)
|
# DYNAMIC CONTENT PAGES (CMS)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -103,14 +176,25 @@ async def generic_content_page(
|
|||||||
|
|
||||||
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
||||||
page_content = page.content
|
page_content = page.content
|
||||||
|
page_title = page.title
|
||||||
if page.is_store_default and store:
|
if page.is_store_default and store:
|
||||||
page_content = content_page_service.resolve_placeholders(page.content, store)
|
page_content = content_page_service.resolve_placeholders(page.content, store)
|
||||||
|
page_title = content_page_service.resolve_placeholders(page.title, store)
|
||||||
|
|
||||||
context = get_storefront_context(request, db=db, page=page)
|
context = get_storefront_context(request, db=db, page=page)
|
||||||
|
context["page_title"] = page_title
|
||||||
context["page_content"] = page_content
|
context["page_content"] = page_content
|
||||||
|
|
||||||
|
# Select template based on page.template field
|
||||||
|
template_map = {
|
||||||
|
"full": "cms/storefront/landing-full.html",
|
||||||
|
"modern": "cms/storefront/landing-modern.html",
|
||||||
|
"minimal": "cms/storefront/landing-minimal.html",
|
||||||
|
}
|
||||||
|
template_name = template_map.get(page.template, "cms/storefront/content-page.html")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"cms/storefront/content-page.html",
|
template_name,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
|
|||||||
description="URL-safe identifier (about, faq, contact, etc.)",
|
description="URL-safe identifier (about, faq, contact, etc.)",
|
||||||
)
|
)
|
||||||
title: str = Field(..., max_length=200, description="Page title")
|
title: str = Field(..., max_length=200, description="Page title")
|
||||||
|
title_translations: dict[str, str] | None = Field(
|
||||||
|
None, description="Title translations keyed by language code"
|
||||||
|
)
|
||||||
content: str = Field(..., description="HTML or Markdown content")
|
content: str = Field(..., description="HTML or Markdown content")
|
||||||
|
content_translations: dict[str, str] | None = Field(
|
||||||
|
None, description="Content translations keyed by language code"
|
||||||
|
)
|
||||||
content_format: str = Field(
|
content_format: str = Field(
|
||||||
default="html", description="Content format: html or markdown"
|
default="html", description="Content format: html or markdown"
|
||||||
)
|
)
|
||||||
template: str = Field(
|
template: str = Field(
|
||||||
default="default",
|
default="default",
|
||||||
max_length=50,
|
max_length=50,
|
||||||
description="Template name (default, minimal, modern)",
|
description="Template name (default, minimal, modern, full)",
|
||||||
)
|
)
|
||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
None, max_length=300, description="SEO meta description"
|
||||||
)
|
)
|
||||||
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
|
meta_description_translations: dict[str, str] | None = Field(
|
||||||
|
None, description="Meta description translations keyed by language code"
|
||||||
|
)
|
||||||
is_published: bool = Field(default=False, description="Publish immediately")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||||
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
|
|||||||
"""Schema for updating a content page (admin)."""
|
"""Schema for updating a content page (admin)."""
|
||||||
|
|
||||||
title: str | None = Field(None, max_length=200)
|
title: str | None = Field(None, max_length=200)
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str | None = None
|
content: str | None = None
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
template: str | None = Field(None, max_length=50)
|
template: str | None = Field(None, max_length=50)
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
meta_description: str | None = Field(None, max_length=300)
|
||||||
meta_keywords: str | None = Field(None, max_length=300)
|
meta_description_translations: dict[str, str] | None = None
|
||||||
is_published: bool | None = None
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
|
|||||||
store_name: str | None
|
store_name: str | None
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
|
title_translations: dict[str, str] | None = None
|
||||||
content: str
|
content: str
|
||||||
|
content_translations: dict[str, str] | None = None
|
||||||
content_format: str
|
content_format: str
|
||||||
template: str | None = None
|
template: str | None = None
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
meta_description_translations: dict[str, str] | None = None
|
||||||
is_published: bool
|
is_published: bool
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
display_order: int
|
display_order: int
|
||||||
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
|
|||||||
meta_description: str | None = Field(
|
meta_description: str | None = Field(
|
||||||
None, max_length=300, description="SEO meta description"
|
None, max_length=300, description="SEO meta description"
|
||||||
)
|
)
|
||||||
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
|
|
||||||
is_published: bool = Field(default=False, description="Publish immediately")
|
is_published: bool = Field(default=False, description="Publish immediately")
|
||||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||||
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
|
|||||||
content: str | None = None
|
content: str | None = None
|
||||||
content_format: str | None = None
|
content_format: str | None = None
|
||||||
meta_description: str | None = Field(None, max_length=300)
|
meta_description: str | None = Field(None, max_length=300)
|
||||||
meta_keywords: str | None = Field(None, max_length=300)
|
|
||||||
is_published: bool | None = None
|
is_published: bool | None = None
|
||||||
show_in_footer: bool | None = None
|
show_in_footer: bool | None = None
|
||||||
show_in_header: bool | None = None
|
show_in_header: bool | None = None
|
||||||
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
content_format: str
|
content_format: str
|
||||||
meta_description: str | None
|
meta_description: str | None
|
||||||
meta_keywords: str | None
|
|
||||||
published_at: str | None
|
published_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -472,7 +473,7 @@ class ContentPageService:
|
|||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
template: str = "default",
|
template: str = "default",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -494,7 +495,7 @@ class ContentPageService:
|
|||||||
content_format: "html" or "markdown"
|
content_format: "html" or "markdown"
|
||||||
template: Template name for landing pages
|
template: Template name for landing pages
|
||||||
meta_description: SEO description
|
meta_description: SEO description
|
||||||
meta_keywords: SEO keywords
|
meta_description_translations: Meta description translations dict
|
||||||
is_published: Publish immediately
|
is_published: Publish immediately
|
||||||
show_in_footer: Show in footer navigation
|
show_in_footer: Show in footer navigation
|
||||||
show_in_header: Show in header navigation
|
show_in_header: Show in header navigation
|
||||||
@@ -515,7 +516,7 @@ class ContentPageService:
|
|||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
published_at=datetime.now(UTC) if is_published else None,
|
published_at=datetime.now(UTC) if is_published else None,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
@@ -541,11 +542,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -573,16 +576,20 @@ class ContentPageService:
|
|||||||
# Update fields if provided
|
# Update fields if provided
|
||||||
if title is not None:
|
if title is not None:
|
||||||
page.title = title
|
page.title = title
|
||||||
|
if title_translations is not None:
|
||||||
|
page.title_translations = title_translations
|
||||||
if content is not None:
|
if content is not None:
|
||||||
page.content = content
|
page.content = content
|
||||||
|
if content_translations is not None:
|
||||||
|
page.content_translations = content_translations
|
||||||
if content_format is not None:
|
if content_format is not None:
|
||||||
page.content_format = content_format
|
page.content_format = content_format
|
||||||
if template is not None:
|
if template is not None:
|
||||||
page.template = template
|
page.template = template
|
||||||
if meta_description is not None:
|
if meta_description is not None:
|
||||||
page.meta_description = meta_description
|
page.meta_description = meta_description
|
||||||
if meta_keywords is not None:
|
if meta_description_translations is not None:
|
||||||
page.meta_keywords = meta_keywords
|
page.meta_description_translations = meta_description_translations
|
||||||
if is_published is not None:
|
if is_published is not None:
|
||||||
page.is_published = is_published
|
page.is_published = is_published
|
||||||
if is_published and not page.published_at:
|
if is_published and not page.published_at:
|
||||||
@@ -698,7 +705,7 @@ class ContentPageService:
|
|||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -725,7 +732,7 @@ class ContentPageService:
|
|||||||
content=content,
|
content=content,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -760,7 +767,7 @@ class ContentPageService:
|
|||||||
content: str,
|
content: str,
|
||||||
content_format: str = "html",
|
content_format: str = "html",
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool = False,
|
is_published: bool = False,
|
||||||
show_in_footer: bool = True,
|
show_in_footer: bool = True,
|
||||||
show_in_header: bool = False,
|
show_in_header: bool = False,
|
||||||
@@ -791,7 +798,7 @@ class ContentPageService:
|
|||||||
is_platform_page=False,
|
is_platform_page=False,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -913,11 +920,13 @@ class ContentPageService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
title_translations: dict[str, str] | None = None,
|
||||||
content: str | None = None,
|
content: str | None = None,
|
||||||
|
content_translations: dict[str, str] | None = None,
|
||||||
content_format: str | None = None,
|
content_format: str | None = None,
|
||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
meta_description: str | None = None,
|
meta_description: str | None = None,
|
||||||
meta_keywords: str | None = None,
|
meta_description_translations: str | None = None,
|
||||||
is_published: bool | None = None,
|
is_published: bool | None = None,
|
||||||
show_in_footer: bool | None = None,
|
show_in_footer: bool | None = None,
|
||||||
show_in_header: bool | None = None,
|
show_in_header: bool | None = None,
|
||||||
@@ -935,11 +944,13 @@ class ContentPageService:
|
|||||||
db,
|
db,
|
||||||
page_id=page_id,
|
page_id=page_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
title_translations=title_translations,
|
||||||
content=content,
|
content=content,
|
||||||
|
content_translations=content_translations,
|
||||||
content_format=content_format,
|
content_format=content_format,
|
||||||
template=template,
|
template=template,
|
||||||
meta_description=meta_description,
|
meta_description=meta_description,
|
||||||
meta_keywords=meta_keywords,
|
meta_description_translations=meta_description_translations,
|
||||||
is_published=is_published,
|
is_published=is_published,
|
||||||
show_in_footer=show_in_footer,
|
show_in_footer=show_in_footer,
|
||||||
show_in_header=show_in_header,
|
show_in_header=show_in_header,
|
||||||
@@ -991,6 +1002,28 @@ class ContentPageService:
|
|||||||
content = content.replace(placeholder, value)
|
content = content.replace(placeholder, value)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_placeholders_deep(data, store) -> Any:
|
||||||
|
"""
|
||||||
|
Recursively resolve {{store_name}} etc. in a nested data structure
|
||||||
|
(dicts, lists, strings). Used for sections JSON in store default pages.
|
||||||
|
"""
|
||||||
|
if not data or not store:
|
||||||
|
return data
|
||||||
|
if isinstance(data, str):
|
||||||
|
return ContentPageService.resolve_placeholders(data, store)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
k: ContentPageService.resolve_placeholders_deep(v, store)
|
||||||
|
for k, v in data.items()
|
||||||
|
}
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [
|
||||||
|
ContentPageService.resolve_placeholders_deep(item, store)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Homepage Sections Management
|
# Homepage Sections Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class StoreThemeService:
|
|||||||
"""
|
"""
|
||||||
from app.modules.tenancy.services.store_service import store_service
|
from app.modules.tenancy.services.store_service import store_service
|
||||||
|
|
||||||
store = store_service.get_store_by_code(db, store_code)
|
store = store_service.get_store_by_code_or_subdomain(db, store_code)
|
||||||
|
|
||||||
if not store:
|
if not store:
|
||||||
self.logger.warning(f"Store not found: {store_code}")
|
self.logger.warning(f"Store not found: {store_code}")
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
|
|||||||
form: {
|
form: {
|
||||||
slug: '',
|
slug: '',
|
||||||
title: '',
|
title: '',
|
||||||
|
title_translations: {},
|
||||||
content: '',
|
content: '',
|
||||||
|
content_translations: {},
|
||||||
content_format: 'html',
|
content_format: 'html',
|
||||||
template: 'default',
|
template: 'default',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
meta_keywords: '',
|
meta_description_translations: {},
|
||||||
is_published: false,
|
is_published: false,
|
||||||
show_in_header: false,
|
show_in_header: false,
|
||||||
show_in_footer: true,
|
show_in_footer: true,
|
||||||
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
|
|||||||
error: null,
|
error: null,
|
||||||
successMessage: null,
|
successMessage: null,
|
||||||
|
|
||||||
|
// Page type: 'content' or 'landing'
|
||||||
|
pageType: 'content',
|
||||||
|
|
||||||
|
// Translation language for title/content
|
||||||
|
titleContentLang: 'fr',
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// HOMEPAGE SECTIONS STATE
|
// HOMEPAGE SECTIONS STATE
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
|
|||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
lb: 'Lëtzebuergesch'
|
lb: 'Lëtzebuergesch'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Template-driven section palette
|
||||||
|
sectionPalette: {
|
||||||
|
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
|
||||||
|
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
|
||||||
|
},
|
||||||
|
|
||||||
sections: {
|
sections: {
|
||||||
hero: {
|
hero: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
|
|||||||
await this.loadPage();
|
await this.loadPage();
|
||||||
contentPageEditLog.groupEnd();
|
contentPageEditLog.groupEnd();
|
||||||
|
|
||||||
// Load sections if this is a homepage
|
// Load sections if this is a landing page
|
||||||
if (this.form.slug === 'home') {
|
if (this.pageType === 'landing') {
|
||||||
await this.loadSections();
|
await this.loadSections();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
|
|||||||
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check if we should show section editor (property, not getter for Alpine compatibility)
|
// Check if we should show section editor
|
||||||
isHomepage: false,
|
isHomepage: false,
|
||||||
|
|
||||||
// Update isHomepage when slug changes
|
// Is a section available for the current template?
|
||||||
|
isSectionAvailable(sectionName) {
|
||||||
|
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
|
||||||
|
return palette.includes(sectionName);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update homepage state
|
||||||
updateIsHomepage() {
|
updateIsHomepage() {
|
||||||
this.isHomepage = this.form.slug === 'home';
|
this.isHomepage = this.form.slug === 'home';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Update template when page type changes
|
||||||
|
updatePageType() {
|
||||||
|
if (this.pageType === 'landing') {
|
||||||
|
this.form.template = 'full';
|
||||||
|
// Load sections if editing and not yet loaded
|
||||||
|
if (this.pageId && !this.sectionsLoaded) {
|
||||||
|
this.loadSections();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.form.template = 'default';
|
||||||
|
}
|
||||||
|
this.updateIsHomepage();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// TITLE/CONTENT TRANSLATION HELPERS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
getTranslatedTitle() {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
return this.form.title;
|
||||||
|
}
|
||||||
|
return (this.form.title_translations || {})[this.titleContentLang] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTranslatedTitle(value) {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
this.form.title = value;
|
||||||
|
} else {
|
||||||
|
if (!this.form.title_translations) this.form.title_translations = {};
|
||||||
|
this.form.title_translations[this.titleContentLang] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTranslatedContent() {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
return this.form.content;
|
||||||
|
}
|
||||||
|
return (this.form.content_translations || {})[this.titleContentLang] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTranslatedContent(value) {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
this.form.content = value;
|
||||||
|
} else {
|
||||||
|
if (!this.form.content_translations) this.form.content_translations = {};
|
||||||
|
this.form.content_translations[this.titleContentLang] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTranslatedMetaDescription() {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
return this.form.meta_description;
|
||||||
|
}
|
||||||
|
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTranslatedMetaDescription(value) {
|
||||||
|
if (this.titleContentLang === this.defaultLanguage) {
|
||||||
|
this.form.meta_description = value;
|
||||||
|
} else {
|
||||||
|
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
|
||||||
|
this.form.meta_description_translations[this.titleContentLang] = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Load platforms for dropdown
|
// Load platforms for dropdown
|
||||||
async loadPlatforms() {
|
async loadPlatforms() {
|
||||||
this.loadingPlatforms = true;
|
this.loadingPlatforms = true;
|
||||||
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
|
|||||||
this.form = {
|
this.form = {
|
||||||
slug: page.slug || '',
|
slug: page.slug || '',
|
||||||
title: page.title || '',
|
title: page.title || '',
|
||||||
|
title_translations: page.title_translations || {},
|
||||||
content: page.content || '',
|
content: page.content || '',
|
||||||
|
content_translations: page.content_translations || {},
|
||||||
content_format: page.content_format || 'html',
|
content_format: page.content_format || 'html',
|
||||||
template: page.template || 'default',
|
template: page.template || 'default',
|
||||||
meta_description: page.meta_description || '',
|
meta_description: page.meta_description || '',
|
||||||
meta_keywords: page.meta_keywords || '',
|
meta_description_translations: page.meta_description_translations || {},
|
||||||
is_published: page.is_published || false,
|
is_published: page.is_published || false,
|
||||||
show_in_header: page.show_in_header || false,
|
show_in_header: page.show_in_header || false,
|
||||||
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
||||||
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
|
|||||||
store_id: page.store_id
|
store_id: page.store_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set page type from template
|
||||||
|
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
|
||||||
|
|
||||||
contentPageEditLog.info('Page loaded successfully');
|
contentPageEditLog.info('Page loaded successfully');
|
||||||
|
|
||||||
// Update computed properties after loading
|
// Update computed properties after loading
|
||||||
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// HOMEPAGE SECTIONS METHODS
|
// SECTIONS METHODS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Load sections for homepage
|
// Load sections for landing pages
|
||||||
async loadSections() {
|
async loadSections() {
|
||||||
if (!this.pageId || this.form.slug !== 'home') {
|
if (!this.pageId || this.pageType !== 'landing') {
|
||||||
contentPageEditLog.debug('Skipping section load - not a homepage');
|
contentPageEditLog.debug('Skipping section load - not a landing page');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Loading homepage sections...');
|
contentPageEditLog.info('Loading sections...');
|
||||||
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
||||||
const data = response.data || response;
|
const data = response.data || response;
|
||||||
|
|
||||||
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
||||||
this.defaultLanguage = data.default_language || 'fr';
|
this.defaultLanguage = data.default_language || 'fr';
|
||||||
this.currentLang = this.defaultLanguage;
|
this.currentLang = this.defaultLanguage;
|
||||||
|
this.titleContentLang = this.defaultLanguage;
|
||||||
|
|
||||||
if (data.sections) {
|
if (data.sections) {
|
||||||
this.sections = this.mergeWithDefaults(data.sections);
|
this.sections = this.mergeWithDefaults(data.sections);
|
||||||
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
|
|||||||
mergeWithDefaults(loadedSections) {
|
mergeWithDefaults(loadedSections) {
|
||||||
const defaults = this.getDefaultSectionStructure();
|
const defaults = this.getDefaultSectionStructure();
|
||||||
|
|
||||||
// Deep merge each section
|
// Deep merge each section that exists in defaults
|
||||||
for (const key of ['hero', 'features', 'pricing', 'cta']) {
|
for (const key of Object.keys(defaults)) {
|
||||||
if (loadedSections[key]) {
|
if (loadedSections[key]) {
|
||||||
defaults[key] = { ...defaults[key], ...loadedSections[key] };
|
defaults[key] = { ...defaults[key], ...loadedSections[key] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Also preserve any extra sections from loaded data
|
||||||
|
for (const key of Object.keys(loadedSections)) {
|
||||||
|
if (!defaults[key]) {
|
||||||
|
defaults[key] = loadedSections[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return defaults;
|
return defaults;
|
||||||
},
|
},
|
||||||
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
|
|||||||
|
|
||||||
// Save sections
|
// Save sections
|
||||||
async saveSections() {
|
async saveSections() {
|
||||||
if (!this.pageId || !this.isHomepage) return;
|
if (!this.pageId || this.pageType !== 'landing') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentPageEditLog.info('Saving sections...');
|
contentPageEditLog.info('Saving sections...');
|
||||||
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
slug: this.form.slug,
|
slug: this.form.slug,
|
||||||
title: this.form.title,
|
title: this.form.title,
|
||||||
|
title_translations: this.form.title_translations,
|
||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
|
content_translations: this.form.content_translations,
|
||||||
content_format: this.form.content_format,
|
content_format: this.form.content_format,
|
||||||
template: this.form.template,
|
template: this.form.template,
|
||||||
meta_description: this.form.meta_description,
|
meta_description: this.form.meta_description,
|
||||||
meta_keywords: this.form.meta_keywords,
|
meta_description_translations: this.form.meta_description_translations,
|
||||||
is_published: this.form.is_published,
|
is_published: this.form.is_published,
|
||||||
show_in_header: this.form.show_in_header,
|
show_in_header: this.form.show_in_header,
|
||||||
show_in_footer: this.form.show_in_footer,
|
show_in_footer: this.form.show_in_footer,
|
||||||
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
|
|||||||
// Update existing page
|
// Update existing page
|
||||||
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
||||||
|
|
||||||
// Also save sections if this is a homepage
|
// Also save sections if this is a landing page
|
||||||
if (this.isHomepage && this.sectionsLoaded) {
|
if (this.pageType === 'landing' && this.sectionsLoaded) {
|
||||||
await this.saveSections();
|
await this.saveSections();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,19 +57,23 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- Page Title -->
|
<!-- Page Type -->
|
||||||
<div class="md:col-span-2">
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Page Title <span class="text-red-500">*</span>
|
Page Type
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
x-model="pageType"
|
||||||
x-model="form.title"
|
@change="updatePageType()"
|
||||||
required
|
|
||||||
maxlength="200"
|
|
||||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||||
placeholder="About Us"
|
|
||||||
>
|
>
|
||||||
|
<option value="content">Content Page</option>
|
||||||
|
<option value="landing">Landing Page</option>
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
|
||||||
|
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slug -->
|
<!-- Slug -->
|
||||||
@@ -133,10 +137,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Title with Language Tabs -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Page Title
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Language Tabs for Title/Content -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex -mb-px space-x-4">
|
||||||
|
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="titleContentLang = lang"
|
||||||
|
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||||
|
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
|
||||||
|
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Title <span class="text-red-500">*</span>
|
||||||
|
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="getTranslatedTitle()"
|
||||||
|
@input="setTranslatedTitle($event.target.value)"
|
||||||
|
required
|
||||||
|
maxlength="200"
|
||||||
|
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||||
|
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content (only for Content Page type) -->
|
||||||
|
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Page Content
|
Page Content
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Content Format -->
|
<!-- Content Format -->
|
||||||
@@ -219,9 +267,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||||
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
|
<!-- SECTIONS EDITOR (for Landing Page type) -->
|
||||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||||
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Homepage Sections
|
Homepage Sections
|
||||||
@@ -258,7 +306,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- HERO SECTION -->
|
<!-- HERO SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||||
@@ -341,7 +389,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- FEATURES SECTION -->
|
<!-- FEATURES SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||||
@@ -410,7 +458,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- PRICING SECTION -->
|
<!-- PRICING SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||||
@@ -448,7 +496,7 @@
|
|||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- CTA SECTION -->
|
<!-- CTA SECTION -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||||
@@ -525,6 +573,7 @@
|
|||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
SEO & Metadata
|
SEO & Metadata
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -534,30 +583,17 @@
|
|||||||
Meta Description
|
Meta Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
x-model="form.meta_description"
|
:value="getTranslatedMetaDescription()"
|
||||||
|
@input="setTranslatedMetaDescription($event.target.value)"
|
||||||
rows="2"
|
rows="2"
|
||||||
maxlength="300"
|
maxlength="300"
|
||||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||||
placeholder="A brief description for search engines"
|
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
|
150-160 characters recommended for search engines
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meta Keywords -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Meta Keywords
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="form.meta_keywords"
|
|
||||||
maxlength="300"
|
|
||||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
|
||||||
placeholder="keyword1, keyword2, keyword3"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -459,5 +459,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-theme.js') }}"></script>
|
<script defer src="{{ url_for('cms_static', path='admin/js/store-theme.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -125,5 +125,5 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
||||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-themes.js') }}"></script>
|
<script defer src="{{ url_for('cms_static', path='admin/js/store-themes.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
{% from 'cms/platform/sections/_products.html' import render_products %}
|
{% from 'cms/platform/sections/_products.html' import render_products %}
|
||||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||||
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
|
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
|
||||||
|
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||||
|
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||||
|
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@@ -51,6 +54,21 @@
|
|||||||
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Testimonials Section #}
|
||||||
|
{% if page.sections.testimonials %}
|
||||||
|
{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Gallery Section #}
|
||||||
|
{% if page.sections.gallery %}
|
||||||
|
{{ render_gallery(page.sections.gallery, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Contact Info Section #}
|
||||||
|
{% if page.sections.contact_info %}
|
||||||
|
{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# CTA Section #}
|
{# CTA Section #}
|
||||||
{% if page.sections.cta %}
|
{% if page.sections.cta %}
|
||||||
{{ render_cta(page.sections.cta, lang, default_lang) }}
|
{{ render_cta(page.sections.cta, lang, default_lang) }}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{# Section partial: Contact Information #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- contact_info: dict with enabled, title, email, phone, address, hours, map_embed_url
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_contact_info(contact_info, lang, default_lang) %}
|
||||||
|
{% if contact_info and contact_info.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = contact_info.title.translations.get(lang) or contact_info.title.translations.get(default_lang) or 'Contact' %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||||
|
{% if contact_info.phone %}
|
||||||
|
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 dark:text-purple-300 text-xl">📞</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Phone</h3>
|
||||||
|
<a href="tel:{{ contact_info.phone }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||||
|
{{ contact_info.phone }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if contact_info.email %}
|
||||||
|
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 dark:text-purple-300 text-xl">📧</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h3>
|
||||||
|
<a href="mailto:{{ contact_info.email }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||||
|
{{ contact_info.email }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if contact_info.address %}
|
||||||
|
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||||
|
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||||
|
<span class="text-purple-600 dark:text-purple-300 text-xl">📍</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Address</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">{{ contact_info.address }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if contact_info.hours %}
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-semibold">Hours:</span> {{ contact_info.hours }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{# Section partial: Image Gallery #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- gallery: dict with enabled, title, images (list of {src, alt, caption})
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_gallery(gallery, lang, default_lang) %}
|
||||||
|
{% if gallery and gallery.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = gallery.title.translations.get(lang) or gallery.title.translations.get(default_lang) or '' %}
|
||||||
|
{% if title %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Image grid #}
|
||||||
|
{% if gallery.images %}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{% for image in gallery.images %}
|
||||||
|
<div class="relative group overflow-hidden rounded-lg aspect-square">
|
||||||
|
<img src="{{ image.src }}"
|
||||||
|
alt="{{ image.alt or '' }}"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||||
|
loading="lazy">
|
||||||
|
{% if image.caption %}
|
||||||
|
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<p class="text-sm text-white">{{ image.caption }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{# Section partial: Testimonials #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- testimonials: dict with enabled, title, subtitle, items
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_testimonials(testimonials, lang, default_lang) %}
|
||||||
|
{% if testimonials and testimonials.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = testimonials.title.translations.get(lang) or testimonials.title.translations.get(default_lang) or '' %}
|
||||||
|
{% if title %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Testimonial cards — use .get() to avoid dict.items() method collision with JSON dicts #}
|
||||||
|
{% set testimonial_items = testimonials.get('items', []) if testimonials is mapping else [] %}
|
||||||
|
{% if testimonial_items %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{% for item in testimonial_items %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="flex text-yellow-400">
|
||||||
|
{% for _ in range(5) %}
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set content = item.content %}
|
||||||
|
{% if content is mapping %}
|
||||||
|
{% set content = content.translations.get(lang) or content.translations.get(default_lang) or '' %}
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6 italic">"{{ content }}"</p>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if item.avatar %}
|
||||||
|
<img src="{{ item.avatar }}" alt="" class="w-10 h-10 rounded-full mr-3">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-3">
|
||||||
|
<span class="text-sm font-bold text-purple-600 dark:text-purple-300">
|
||||||
|
{% set author = item.author %}
|
||||||
|
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '?' %}{% endif %}
|
||||||
|
{{ author[0]|upper if author else '?' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
{% set author = item.author %}
|
||||||
|
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '' %}{% endif %}
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ author }}</p>
|
||||||
|
{% set role = item.role %}
|
||||||
|
{% if role is mapping %}{% set role = role.translations.get(lang) or role.translations.get(default_lang) or '' %}{% endif %}
|
||||||
|
{% if role %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ role }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center text-gray-400 dark:text-gray-500">Coming soon</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% extends "storefront/base.html" %}
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
{# Dynamic title from CMS #}
|
{# Dynamic title from CMS #}
|
||||||
{% block title %}{{ page.title }}{% endblock %}
|
{% block title %}{{ page_title or page.title }}{% endblock %}
|
||||||
|
|
||||||
{# SEO from CMS #}
|
{# SEO from CMS #}
|
||||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||||
@@ -16,13 +16,13 @@
|
|||||||
<div class="breadcrumb mb-6">
|
<div class="breadcrumb mb-6">
|
||||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
|
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Page Header #}
|
{# Page Header #}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
||||||
{{ page.title }}
|
{{ page_title or page.title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{# Optional: Show store override badge for debugging #}
|
{# Optional: Show store override badge for debugging #}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
{# app/templates/store/landing-default.html #}
|
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
|
||||||
{# standalone #}
|
|
||||||
{# Default/Minimal Landing Page Template #}
|
{# Default/Minimal Landing Page Template #}
|
||||||
{% extends "storefront/base.html" %}
|
{% extends "storefront/base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ store.name }}{% endblock %}
|
{% block title %}{{ store.name }}{% endblock %}
|
||||||
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
|
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
|
|
||||||
{# Title #}
|
{# Title #}
|
||||||
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
{{ page.title or store.name }}
|
{{ page_title or store.name }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{# Tagline #}
|
{# Tagline #}
|
||||||
@@ -34,18 +33,31 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# CTA Button #}
|
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
|
||||||
|
{% set nav_items = storefront_nav.get('nav', []) %}
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<a href="{{ base_url }}"
|
{% if nav_items %}
|
||||||
|
{# Primary CTA: first nav item from enabled modules #}
|
||||||
|
<a href="{{ base_url }}{{ nav_items[0].route }}"
|
||||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||||
style="background-color: var(--color-primary)">
|
style="background-color: var(--color-primary)">
|
||||||
Browse Our Shop
|
{{ _(nav_items[0].label_key) }}
|
||||||
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||||
</a>
|
</a>
|
||||||
{% if page.content %}
|
{% else %}
|
||||||
|
{# Fallback: account link when no module nav items #}
|
||||||
|
<a href="{{ base_url }}account/login"
|
||||||
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||||
|
style="background-color: var(--color-primary)">
|
||||||
|
{{ _('cms.storefront.my_account') }}
|
||||||
|
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page and page.content %}
|
||||||
<a href="#about"
|
<a href="#about"
|
||||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
|
||||||
Learn More
|
{{ _('cms.storefront.learn_more') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,73 +66,65 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# Content Section (if provided) #}
|
{# Content Section (if provided) #}
|
||||||
{% if page.content %}
|
{% if page_content %}
|
||||||
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
||||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
{{ page_content | safe }}{# sanitized: CMS content #}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Quick Links Section #}
|
{# Quick Links Section — driven by nav items and CMS pages #}
|
||||||
|
{% set account_items = storefront_nav.get('account', []) %}
|
||||||
|
{% set all_links = nav_items + account_items %}
|
||||||
|
{% if all_links or header_pages %}
|
||||||
<section class="py-16 bg-gray-50 dark:bg-gray-800">
|
<section class="py-16 bg-gray-50 dark:bg-gray-800">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
||||||
Explore
|
{{ _('cms.storefront.explore') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<a href="{{ base_url }}products"
|
{# Module nav items (products, loyalty, etc.) #}
|
||||||
|
{% for item in all_links[:3] %}
|
||||||
|
<a href="{{ base_url }}{{ item.route }}"
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||||
<div class="text-4xl mb-4">🛍️</div>
|
<div class="mb-4">
|
||||||
|
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||||
|
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
|
||||||
|
</div>
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||||
Shop Products
|
{{ _(item.label_key) }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Browse our complete catalog
|
|
||||||
</p>
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if header_pages %}
|
{# Fill remaining slots with CMS header pages #}
|
||||||
{% for page in header_pages[:2] %}
|
{% set remaining = 3 - all_links[:3]|length %}
|
||||||
|
{% if remaining > 0 and header_pages %}
|
||||||
|
{% for page in header_pages[:remaining] %}
|
||||||
<a href="{{ base_url }}{{ page.slug }}"
|
<a href="{{ base_url }}{{ page.slug }}"
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||||
<div class="text-4xl mb-4">📄</div>
|
<div class="mb-4">
|
||||||
|
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||||
|
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
|
||||||
|
</div>
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
{% if page.meta_description %}
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
{{ page.meta_description or 'Learn more' }}
|
{{ page.meta_description }}
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
|
||||||
<a href="{{ base_url }}about"
|
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
|
||||||
<div class="text-4xl mb-4">ℹ️</div>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
|
||||||
About Us
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Learn about our story
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="{{ base_url }}contact"
|
|
||||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
|
||||||
<div class="text-4xl mb-4">📧</div>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
|
||||||
Contact
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Get in touch with us
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,6 +10,35 @@
|
|||||||
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
|
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
||||||
|
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
|
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
|
||||||
|
{% if sections %}
|
||||||
|
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||||
|
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||||
|
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||||
|
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||||
|
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||||
|
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||||
|
|
||||||
|
{% set lang = request.state.language|default("fr") %}
|
||||||
|
{% set default_lang = 'fr' %}
|
||||||
|
|
||||||
|
<div class="min-h-screen">
|
||||||
|
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
|
||||||
|
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
|
{# HARDCODED LAYOUT (original full landing page — no sections JSON) #}
|
||||||
|
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
|
|
||||||
{# Hero Section - Split Design #}
|
{# Hero Section - Split Design #}
|
||||||
@@ -255,4 +284,5 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Config File Pattern:
|
|||||||
api_timeout: int = Field(default=30, description="API timeout in seconds")
|
api_timeout: int = Field(default=30, description="API timeout in seconds")
|
||||||
max_retries: int = Field(default=3, description="Max retry attempts")
|
max_retries: int = Field(default=3, description="Max retry attempts")
|
||||||
|
|
||||||
model_config = {"env_prefix": "MYMODULE_"}
|
model_config = {"env_prefix": "MYMODULE_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
# Export the config class and instance
|
# Export the config class and instance
|
||||||
config_class = MyModuleConfig
|
config_class = MyModuleConfig
|
||||||
|
|||||||
@@ -80,6 +80,44 @@ class WidgetContext:
|
|||||||
include_details: bool = False
|
include_details: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Storefront Dashboard Card
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StorefrontDashboardCard:
|
||||||
|
"""
|
||||||
|
A card contributed by a module to the storefront customer dashboard.
|
||||||
|
|
||||||
|
Modules implement get_storefront_dashboard_cards() to provide these.
|
||||||
|
The dashboard template renders them without knowing which module provided them.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
|
||||||
|
icon: Lucide icon name (e.g. "shopping-bag", "gift")
|
||||||
|
title: Card title (i18n key or plain text)
|
||||||
|
subtitle: Card subtitle / description
|
||||||
|
route: Link destination relative to base_url (e.g. "account/orders")
|
||||||
|
value: Primary display value (e.g. order count, points balance)
|
||||||
|
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
|
||||||
|
order: Sort order (lower = shown first)
|
||||||
|
template: Optional custom template path for complex rendering
|
||||||
|
extra_data: Additional data for custom template rendering
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
icon: str
|
||||||
|
title: str
|
||||||
|
subtitle: str
|
||||||
|
route: str
|
||||||
|
value: str | int | None = None
|
||||||
|
value_label: str | None = None
|
||||||
|
order: int = 100
|
||||||
|
template: str | None = None
|
||||||
|
extra_data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Widget Item Types
|
# Widget Item Types
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def get_storefront_dashboard_cards(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
store_id: int,
|
||||||
|
customer_id: int,
|
||||||
|
context: WidgetContext | None = None,
|
||||||
|
) -> list["StorefrontDashboardCard"]:
|
||||||
|
"""
|
||||||
|
Get cards for the storefront customer dashboard.
|
||||||
|
|
||||||
|
Called by the customer account dashboard. Each module contributes
|
||||||
|
its own cards (e.g. orders summary, loyalty points).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session for queries
|
||||||
|
store_id: ID of the store
|
||||||
|
customer_id: ID of the logged-in customer
|
||||||
|
context: Optional filtering/scoping context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of StorefrontDashboardCard objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Context
|
# Context
|
||||||
@@ -343,6 +405,8 @@ __all__ = [
|
|||||||
"WidgetData",
|
"WidgetData",
|
||||||
# Main envelope
|
# Main envelope
|
||||||
"DashboardWidget",
|
"DashboardWidget",
|
||||||
|
# Storefront
|
||||||
|
"StorefrontDashboardCard",
|
||||||
# Protocol
|
# Protocol
|
||||||
"DashboardWidgetProviderProtocol",
|
"DashboardWidgetProviderProtocol",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async def admin_login_page(
|
|||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"current_language": language,
|
"current_language": language,
|
||||||
|
"frontend_type": "admin",
|
||||||
**get_jinja2_globals(language),
|
**get_jinja2_globals(language),
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async def merchant_login_page(
|
|||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
"current_language": language,
|
"current_language": language,
|
||||||
|
"frontend_type": "merchant",
|
||||||
**get_jinja2_globals(language),
|
**get_jinja2_globals(language),
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ Store pages for core functionality:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
|
UserContext,
|
||||||
get_current_store_from_cookie_or_header,
|
get_current_store_from_cookie_or_header,
|
||||||
|
get_current_store_optional,
|
||||||
get_db,
|
get_db,
|
||||||
get_resolved_store_code,
|
get_resolved_store_code,
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,21 @@ from app.templates_config import templates
|
|||||||
router = APIRouter()
|
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
|
# STORE DASHBOARD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
|
|||||||
section_order: int
|
section_order: int
|
||||||
is_visible: bool = True
|
is_visible: bool = True
|
||||||
is_module_enabled: bool = True
|
is_module_enabled: bool = True
|
||||||
|
header_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
|
|||||||
section_label_key=section.label_key,
|
section_label_key=section.label_key,
|
||||||
section_order=section.order,
|
section_order=section.order,
|
||||||
is_module_enabled=is_module_enabled,
|
is_module_enabled=is_module_enabled,
|
||||||
|
header_template=item.header_template,
|
||||||
)
|
)
|
||||||
sections_map[section.id].items.append(discovered_item)
|
sections_map[section.id].items.append(discovered_item)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.modules.contracts.widgets import (
|
from app.modules.contracts.widgets import (
|
||||||
DashboardWidget,
|
DashboardWidget,
|
||||||
DashboardWidgetProviderProtocol,
|
DashboardWidgetProviderProtocol,
|
||||||
|
StorefrontDashboardCard,
|
||||||
WidgetContext,
|
WidgetContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
|
|||||||
return widget
|
return widget
|
||||||
return None
|
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(
|
def get_available_categories(
|
||||||
self, db: Session, platform_id: int
|
self, db: Session, platform_id: int
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
* Works with store-specific themes
|
* Works with store-specific themes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const shopLog = {
|
const shopLog = window.LogConfig?.createLogger('STOREFRONT') || {
|
||||||
info: (...args) => console.info('🛒 [SHOP]', ...args),
|
info: (...args) => console.info('🛒 [STOREFRONT]', ...args),
|
||||||
warn: (...args) => console.warn('⚠️ [SHOP]', ...args),
|
warn: (...args) => console.warn('⚠️ [STOREFRONT]', ...args),
|
||||||
error: (...args) => console.error('❌ [SHOP]', ...args),
|
error: (...args) => console.error('❌ [STOREFRONT]', ...args),
|
||||||
debug: (...args) => console.log('🔍 [SHOP]', ...args)
|
debug: (...args) => console.log('🔍 [STOREFRONT]', ...args)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ class TestMerchantMenuModuleGating:
|
|||||||
s for s in data["sections"] if s["id"] == platform_section_id
|
s for s in data["sections"] if s["id"] == platform_section_id
|
||||||
)
|
)
|
||||||
item_ids = {i["id"] for i in platform_section["items"]}
|
item_ids = {i["id"] for i in platform_section["items"]}
|
||||||
assert "loyalty-program" in item_ids
|
assert "program" in item_ids
|
||||||
|
|
||||||
def test_loyalty_hidden_when_module_not_enabled(
|
def test_loyalty_hidden_when_module_not_enabled(
|
||||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||||
@@ -304,7 +304,7 @@ class TestMerchantMenuModuleGating:
|
|||||||
s for s in data["sections"] if s["id"] == platform_section_id
|
s for s in data["sections"] if s["id"] == platform_section_id
|
||||||
)
|
)
|
||||||
overview = next(
|
overview = next(
|
||||||
i for i in platform_section["items"] if i["id"] == "loyalty-program"
|
i for i in platform_section["items"] if i["id"] == "program"
|
||||||
)
|
)
|
||||||
assert overview["url"] == "/merchants/loyalty/program"
|
assert overview["url"] == "/merchants/loyalty/program"
|
||||||
|
|
||||||
@@ -498,7 +498,7 @@ class TestMerchantMenuMultiPlatform:
|
|||||||
s for s in data["sections"] if s["id"] == platform_a_section_id
|
s for s in data["sections"] if s["id"] == platform_a_section_id
|
||||||
)
|
)
|
||||||
item_ids = {i["id"] for i in pa_section["items"]}
|
item_ids = {i["id"] for i in pa_section["items"]}
|
||||||
assert "loyalty-program" in item_ids
|
assert "program" in item_ids
|
||||||
# Core sections always present
|
# Core sections always present
|
||||||
assert "main" in section_ids
|
assert "main" in section_ids
|
||||||
assert "billing" in section_ids
|
assert "billing" in section_ids
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ class TestMenuDiscoveryService:
|
|||||||
assert "profile" in item_ids
|
assert "profile" in item_ids
|
||||||
|
|
||||||
def test_merchant_loyalty_section_items(self):
|
def test_merchant_loyalty_section_items(self):
|
||||||
"""Loyalty section contains loyalty-program."""
|
"""Loyalty section contains program."""
|
||||||
menus = self.service.discover_all_menus()
|
menus = self.service.discover_all_menus()
|
||||||
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
|
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
|
||||||
assert len(loyalty_sections) == 1
|
assert len(loyalty_sections) == 1
|
||||||
item_ids = [i.id for i in loyalty_sections[0].items]
|
item_ids = [i.id for i in loyalty_sections[0].items]
|
||||||
assert "loyalty-program" in item_ids
|
assert "program" in item_ids
|
||||||
|
|
||||||
def test_get_all_menu_items_merchant(self):
|
def test_get_all_menu_items_merchant(self):
|
||||||
"""get_all_menu_items returns items for MERCHANT frontend type."""
|
"""get_all_menu_items returns items for MERCHANT frontend type."""
|
||||||
@@ -75,7 +75,7 @@ class TestMenuDiscoveryService:
|
|||||||
item_ids = {i.id for i in items}
|
item_ids = {i.id for i in items}
|
||||||
assert "dashboard" in item_ids
|
assert "dashboard" in item_ids
|
||||||
assert "subscriptions" in item_ids
|
assert "subscriptions" in item_ids
|
||||||
assert "loyalty-program" in item_ids
|
assert "program" in item_ids
|
||||||
|
|
||||||
def test_get_all_menu_item_ids_merchant(self):
|
def test_get_all_menu_item_ids_merchant(self):
|
||||||
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
|
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
|
||||||
@@ -85,7 +85,7 @@ class TestMenuDiscoveryService:
|
|||||||
assert "invoices" in item_ids
|
assert "invoices" in item_ids
|
||||||
assert "stores" in item_ids
|
assert "stores" in item_ids
|
||||||
assert "profile" in item_ids
|
assert "profile" in item_ids
|
||||||
assert "loyalty-program" in item_ids
|
assert "program" in item_ids
|
||||||
|
|
||||||
def test_get_mandatory_item_ids_merchant(self):
|
def test_get_mandatory_item_ids_merchant(self):
|
||||||
"""Mandatory items for MERCHANT include dashboard and subscriptions."""
|
"""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
|
# Pass enabled module codes to templates for conditional rendering
|
||||||
context["enabled_modules"] = enabled_module_codes
|
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
|
# For storefront, build nav menu structure from module declarations
|
||||||
if frontend_type == FrontendType.STOREFRONT:
|
if frontend_type == FrontendType.STOREFRONT:
|
||||||
from app.modules.core.services.menu_discovery_service import (
|
from app.modules.core.services.menu_discovery_service import (
|
||||||
@@ -381,15 +384,17 @@ def get_storefront_context(
|
|||||||
if access_method == "path" and store:
|
if access_method == "path" and store:
|
||||||
platform = getattr(request.state, "platform", None)
|
platform = getattr(request.state, "platform", None)
|
||||||
platform_original_path = getattr(request.state, "platform_original_path", 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/"):
|
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:
|
else:
|
||||||
full_prefix = (
|
full_prefix = (
|
||||||
store_context.get("full_prefix", "/storefront/")
|
store_context.get("full_prefix", "/storefront/")
|
||||||
if store_context
|
if store_context
|
||||||
else "/storefront/"
|
else "/storefront/"
|
||||||
)
|
)
|
||||||
base_url = f"{full_prefix}{store.store_code}/"
|
base_url = f"{full_prefix}{store_slug}/"
|
||||||
|
|
||||||
# Read subscription info set by StorefrontAccessMiddleware
|
# Read subscription info set by StorefrontAccessMiddleware
|
||||||
subscription = getattr(request.state, "subscription", None)
|
subscription = getattr(request.state, "subscription", None)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "CUSTOMERS_"}
|
model_config = {"env_prefix": "CUSTOMERS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
|
|||||||
items=[
|
items=[
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="dashboard",
|
id="dashboard",
|
||||||
label_key="storefront.account.dashboard",
|
label_key="customers.storefront.account.dashboard",
|
||||||
icon="home",
|
icon="home",
|
||||||
route="account/dashboard",
|
route="account/dashboard",
|
||||||
order=10,
|
order=10,
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="profile",
|
id="profile",
|
||||||
label_key="storefront.account.profile",
|
label_key="customers.storefront.account.profile",
|
||||||
icon="user",
|
icon="user",
|
||||||
route="account/profile",
|
route="account/profile",
|
||||||
order=20,
|
order=20,
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="addresses",
|
id="addresses",
|
||||||
label_key="storefront.account.addresses",
|
label_key="customers.storefront.account.addresses",
|
||||||
icon="map-pin",
|
icon="map-pin",
|
||||||
route="account/addresses",
|
route="account/addresses",
|
||||||
order=30,
|
order=30,
|
||||||
),
|
),
|
||||||
MenuItemDefinition(
|
MenuItemDefinition(
|
||||||
id="settings",
|
id="settings",
|
||||||
label_key="storefront.account.settings",
|
label_key="customers.storefront.account.settings",
|
||||||
icon="cog",
|
icon="cog",
|
||||||
route="account/settings",
|
route="account/settings",
|
||||||
order=90,
|
order=90,
|
||||||
|
|||||||
@@ -52,5 +52,13 @@
|
|||||||
"customers_delete_desc": "Kundendatensätze entfernen",
|
"customers_delete_desc": "Kundendatensätze entfernen",
|
||||||
"customers_export": "Kunden exportieren",
|
"customers_export": "Kunden exportieren",
|
||||||
"customers_export_desc": "Kundendaten exportieren"
|
"customers_export_desc": "Kundendaten exportieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"account": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"profile": "Profil",
|
||||||
|
"addresses": "Adressen",
|
||||||
|
"settings": "Einstellungen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,13 @@
|
|||||||
"customers_section": "Customers",
|
"customers_section": "Customers",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
"all_customers": "All Customers"
|
"all_customers": "All Customers"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"account": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"profile": "Profile",
|
||||||
|
"addresses": "Addresses",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,13 @@
|
|||||||
"customers_delete_desc": "Supprimer les fiches clients",
|
"customers_delete_desc": "Supprimer les fiches clients",
|
||||||
"customers_export": "Exporter les clients",
|
"customers_export": "Exporter les clients",
|
||||||
"customers_export_desc": "Exporter les données clients"
|
"customers_export_desc": "Exporter les données clients"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"account": {
|
||||||
|
"dashboard": "Tableau de bord",
|
||||||
|
"profile": "Profil",
|
||||||
|
"addresses": "Adresses",
|
||||||
|
"settings": "Paramètres"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,5 +52,13 @@
|
|||||||
"customers_delete_desc": "Clientedossieren ewechhuelen",
|
"customers_delete_desc": "Clientedossieren ewechhuelen",
|
||||||
"customers_export": "Clienten exportéieren",
|
"customers_export": "Clienten exportéieren",
|
||||||
"customers_export_desc": "Clientedaten exportéieren"
|
"customers_export_desc": "Clientedaten exportéieren"
|
||||||
|
},
|
||||||
|
"storefront": {
|
||||||
|
"account": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"profile": "Profil",
|
||||||
|
"addresses": "Adressen",
|
||||||
|
"settings": "Astellungen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
JSON,
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
|
Date,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
@@ -17,10 +18,10 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
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."""
|
"""Customer model with store isolation."""
|
||||||
|
|
||||||
__tablename__ = "customers"
|
__tablename__ = "customers"
|
||||||
@@ -34,6 +35,7 @@ class Customer(Base, TimestampMixin):
|
|||||||
first_name = Column(String(100))
|
first_name = Column(String(100))
|
||||||
last_name = Column(String(100))
|
last_name = Column(String(100))
|
||||||
phone = Column(String(50))
|
phone = Column(String(50))
|
||||||
|
birth_date = Column(Date, nullable=True)
|
||||||
customer_number = Column(
|
customer_number = Column(
|
||||||
String(100), nullable=False, index=True
|
String(100), nullable=False, index=True
|
||||||
) # Store-specific ID
|
) # 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(
|
return templates.TemplateResponse(
|
||||||
"customers/storefront/dashboard.html",
|
"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
|
- Admin customer management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
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)
|
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
last_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)
|
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
|
marketing_consent: bool | None = None
|
||||||
preferred_language: str | None = Field(
|
preferred_language: str | None = Field(
|
||||||
None, description="Preferred language (en, fr, de, lb)"
|
None, description="Preferred language (en, fr, de, lb)"
|
||||||
@@ -71,6 +74,21 @@ class CustomerUpdate(BaseModel):
|
|||||||
"""Convert email to lowercase."""
|
"""Convert email to lowercase."""
|
||||||
return v.lower() if v else None
|
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):
|
class CustomerPasswordChange(BaseModel):
|
||||||
"""Schema for customer password change."""
|
"""Schema for customer password change."""
|
||||||
@@ -108,6 +126,7 @@ class CustomerResponse(BaseModel):
|
|||||||
first_name: str | None
|
first_name: str | None
|
||||||
last_name: str | None
|
last_name: str | None
|
||||||
phone: str | None
|
phone: str | None
|
||||||
|
birth_date: date | None = None
|
||||||
customer_number: str
|
customer_number: str
|
||||||
marketing_consent: bool
|
marketing_consent: bool
|
||||||
preferred_language: str | None
|
preferred_language: str | None
|
||||||
@@ -253,6 +272,7 @@ class CustomerDetailResponse(BaseModel):
|
|||||||
first_name: str | None = None
|
first_name: str | None = None
|
||||||
last_name: str | None = None
|
last_name: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
|
birth_date: date | None = None
|
||||||
customer_number: str | None = None
|
customer_number: str | None = None
|
||||||
marketing_consent: bool | None = None
|
marketing_consent: bool | None = None
|
||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
@@ -304,6 +324,7 @@ class AdminCustomerItem(BaseModel):
|
|||||||
first_name: str | None = None
|
first_name: str | None = None
|
||||||
last_name: str | None = None
|
last_name: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
|
birth_date: date | None = None
|
||||||
customer_number: str
|
customer_number: str
|
||||||
marketing_consent: bool = False
|
marketing_consent: bool = False
|
||||||
preferred_language: str | None = None
|
preferred_language: str | None = None
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ with complete store isolation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
@@ -567,6 +567,7 @@ class CustomerService:
|
|||||||
first_name: str = "",
|
first_name: str = "",
|
||||||
last_name: str = "",
|
last_name: str = "",
|
||||||
phone: str | None = None,
|
phone: str | None = None,
|
||||||
|
birth_date: date | None = None,
|
||||||
) -> Customer:
|
) -> Customer:
|
||||||
"""
|
"""
|
||||||
Create a customer for loyalty/external enrollment.
|
Create a customer for loyalty/external enrollment.
|
||||||
@@ -580,6 +581,7 @@ class CustomerService:
|
|||||||
first_name: First name
|
first_name: First name
|
||||||
last_name: Last name
|
last_name: Last name
|
||||||
phone: Phone number
|
phone: Phone number
|
||||||
|
birth_date: Date of birth (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created Customer object
|
Created Customer object
|
||||||
@@ -603,6 +605,7 @@ class CustomerService:
|
|||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
last_name=last_name,
|
last_name=last_name,
|
||||||
phone=phone,
|
phone=phone,
|
||||||
|
birth_date=birth_date,
|
||||||
hashed_password=unusable_hash,
|
hashed_password=unusable_hash,
|
||||||
customer_number=cust_number,
|
customer_number=cust_number,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div x-show="!loading && !error && addresses.length === 0"
|
<div x-show="!loading && !error && addresses.length === 0"
|
||||||
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
|
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
|
||||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
|
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
|
||||||
<button @click="openAddModal()"
|
<button @click="openAddModal()"
|
||||||
|
|||||||
@@ -17,25 +17,31 @@
|
|||||||
<!-- Dashboard Grid -->
|
<!-- Dashboard Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Orders Card -->
|
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
|
||||||
<a href="{{ base_url }}account/orders"
|
{% for card in dashboard_cards|default([]) %}
|
||||||
|
<a href="{{ base_url }}{{ card.route }}"
|
||||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
|
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if card.value is not none %}
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
|
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
|
{% if card.value_label %}
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Profile Card -->
|
<!-- Profile Card (always shown — core) -->
|
||||||
<a href="{{ base_url }}account/profile"
|
<a href="{{ base_url }}account/profile"
|
||||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
@@ -52,12 +58,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Addresses Card -->
|
<!-- Addresses Card (always shown — core) -->
|
||||||
<a href="{{ base_url }}account/addresses"
|
<a href="{{ base_url }}account/addresses"
|
||||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
|
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
||||||
@@ -66,36 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if 'loyalty' in enabled_modules %}
|
<!-- Messages Card (always shown — messaging is core) -->
|
||||||
<!-- Loyalty Rewards Card -->
|
|
||||||
<a href="{{ base_url }}account/loyalty"
|
|
||||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
|
||||||
x-data="{ points: null, loaded: false }"
|
|
||||||
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<template x-if="loaded && points !== null">
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="loaded && points === null">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Messages Card -->
|
|
||||||
<a href="{{ base_url }}account/messages"
|
<a href="{{ base_url }}account/messages"
|
||||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||||
x-data="{ unreadCount: 0 }"
|
x-data="{ unreadCount: 0 }"
|
||||||
@@ -126,10 +103,6 @@
|
|||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
|
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
||||||
|
|||||||
@@ -181,6 +181,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Icons (registers $icon magic helper for Alpine) -->
|
||||||
|
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
||||||
|
|
||||||
<!-- Alpine.js v3 -->
|
<!-- Alpine.js v3 -->
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# api_timeout: int = 30
|
# api_timeout: int = 30
|
||||||
# batch_size: int = 100
|
# batch_size: int = 100
|
||||||
|
|
||||||
model_config = {"env_prefix": "DEV_TOOLS_"}
|
model_config = {"env_prefix": "DEV_TOOLS_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def run_domain_health_check(db: Session) -> dict:
|
|||||||
|
|
||||||
# Pre-fetch all active stores, platforms, and memberships
|
# Pre-fetch all active stores, platforms, and memberships
|
||||||
active_stores = (
|
active_stores = (
|
||||||
db.query(Store).filter(Store.is_active.is_(True)).all()
|
db.query(Store).filter(Store.is_active.is_(True)).all() # noqa: SVC-005
|
||||||
)
|
)
|
||||||
store_by_id: dict[int, Store] = {s.id: s for s in active_stores}
|
store_by_id: dict[int, Store] = {s.id: s for s in active_stores}
|
||||||
|
|
||||||
|
|||||||
@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
|
|||||||
|
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
result = connection.execute(text(sql))
|
result = connection.execute(text(sql))
|
||||||
|
columns = list(result.keys()) if result.returns_rows else []
|
||||||
rows_raw = result.fetchmany(max_rows + 1)
|
rows_raw = result.fetchmany(max_rows + 1)
|
||||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||||
|
|
||||||
columns = list(result.keys()) if result.returns_rows else []
|
|
||||||
truncated = len(rows_raw) > max_rows
|
truncated = len(rows_raw) > max_rows
|
||||||
rows_raw = rows_raw[:max_rows]
|
rows_raw = rows_raw[:max_rows]
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ function sqlQueryTool() {
|
|||||||
// Schema explorer
|
// Schema explorer
|
||||||
showPresets: true,
|
showPresets: true,
|
||||||
expandedCategories: {},
|
expandedCategories: {},
|
||||||
presetQueries: [
|
presetSearch: '',
|
||||||
|
|
||||||
|
// Preset sections — grouped by platform
|
||||||
|
presetSections: [
|
||||||
|
// ── Infrastructure ──
|
||||||
|
{
|
||||||
|
label: 'Infrastructure',
|
||||||
|
groups: [
|
||||||
{
|
{
|
||||||
category: 'Schema',
|
category: 'Schema',
|
||||||
items: [
|
items: [
|
||||||
@@ -58,6 +65,13 @@ function sqlQueryTool() {
|
|||||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Core ──
|
||||||
|
{
|
||||||
|
label: 'Core',
|
||||||
|
groups: [
|
||||||
{
|
{
|
||||||
category: 'Tenancy',
|
category: 'Tenancy',
|
||||||
items: [
|
items: [
|
||||||
@@ -65,7 +79,8 @@ function sqlQueryTool() {
|
|||||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||||
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
|
{ name: 'Merchant domains', sql: "SELECT md.id, m.name AS merchant_name,\n md.domain, md.is_primary, md.is_active,\n md.ssl_status, md.is_verified\nFROM merchant_domains md\nJOIN merchants m ON m.id = md.merchant_id\nORDER BY m.name, md.domain;" },
|
||||||
|
{ name: 'Store domains', sql: "SELECT sd.id, s.name AS store_name,\n sd.domain, sd.is_primary, sd.is_active,\n sd.ssl_status, sd.is_verified\nFROM store_domains sd\nJOIN stores s ON s.id = sd.store_id\nORDER BY s.name, sd.domain;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,43 +94,13 @@ function sqlQueryTool() {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'System',
|
category: 'Admin & Audit',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
{ name: 'Audit log', sql: "SELECT al.id, u.email AS admin_email,\n al.action, al.target_type, al.target_id,\n al.ip_address, al.created_at\nFROM admin_audit_logs al\nJOIN users u ON u.id = al.admin_user_id\nORDER BY al.created_at DESC\nLIMIT 100;" },
|
||||||
]
|
{ name: 'Active sessions', sql: "SELECT s.id, u.email AS admin_email,\n s.ip_address, s.login_at, s.last_activity_at,\n s.is_active, s.logout_reason\nFROM admin_sessions s\nJOIN users u ON u.id = s.admin_user_id\nORDER BY s.last_activity_at DESC\nLIMIT 50;" },
|
||||||
},
|
{ name: 'Admin settings', sql: "SELECT id, key, value, value_type,\n category, is_encrypted, is_public\nFROM admin_settings\nORDER BY category, key;" },
|
||||||
{
|
{ name: 'Platform alerts', sql: "SELECT id, alert_type, severity, title,\n is_resolved, occurrence_count,\n first_occurred_at, last_occurred_at\nFROM platform_alerts\nORDER BY last_occurred_at DESC\nLIMIT 50;" },
|
||||||
category: 'Loyalty',
|
{ name: 'Application logs', sql: "SELECT id, timestamp, level, logger_name,\n module, message, exception_type,\n request_id\nFROM application_logs\nORDER BY timestamp DESC\nLIMIT 100;" },
|
||||||
items: [
|
|
||||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
|
||||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
|
||||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
|
||||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Billing',
|
|
||||||
items: [
|
|
||||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
|
||||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
|
||||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Orders',
|
|
||||||
items: [
|
|
||||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
|
||||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: 'Catalog',
|
|
||||||
items: [
|
|
||||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
|
||||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,13 +108,20 @@ function sqlQueryTool() {
|
|||||||
items: [
|
items: [
|
||||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Password reset tokens', sql: "SELECT prt.id, c.email AS customer_email,\n prt.expires_at, prt.used_at, prt.created_at\nFROM password_reset_tokens prt\nJOIN customers c ON c.id = prt.customer_id\nORDER BY prt.created_at DESC\nLIMIT 50;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Inventory',
|
category: 'Messaging',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Conversations', sql: "SELECT cv.id, cv.conversation_type, cv.subject,\n s.name AS store_name, cv.is_closed,\n cv.message_count, cv.last_message_at\nFROM conversations cv\nLEFT JOIN stores s ON s.id = cv.store_id\nORDER BY cv.last_message_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Messages', sql: "SELECT m.id, m.conversation_id,\n m.sender_type, m.sender_id,\n LEFT(m.content, 100) AS content_preview,\n m.is_system_message, m.created_at\nFROM messages m\nORDER BY m.created_at DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Message attachments', sql: "SELECT ma.id, ma.message_id,\n ma.original_filename, ma.mime_type,\n ma.file_size, ma.is_image, ma.created_at\nFROM message_attachments ma\nORDER BY ma.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Store email templates', sql: "SELECT set_.id, s.name AS store_name,\n set_.template_code, set_.language,\n set_.name, set_.is_active\nFROM store_email_templates set_\nJOIN stores s ON s.id = set_.store_id\nORDER BY s.name, set_.template_code;" },
|
||||||
|
{ name: 'Store email settings', sql: "SELECT ses.id, s.name AS store_name,\n ses.from_email, ses.from_name, ses.provider,\n ses.is_configured, ses.is_verified\nFROM store_email_settings ses\nJOIN stores s ON s.id = ses.store_id\nORDER BY s.name;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,28 +133,179 @@ function sqlQueryTool() {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Messaging',
|
category: 'Billing',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||||
|
{ name: 'Tier feature limits', sql: "SELECT tfl.id, st.code AS tier_code,\n st.name AS tier_name, tfl.feature_code,\n tfl.limit_value\nFROM tier_feature_limits tfl\nJOIN subscription_tiers st ON st.id = tfl.tier_id\nORDER BY st.code, tfl.feature_code;" },
|
||||||
|
{ name: 'Merchant feature overrides', sql: "SELECT mfo.id, m.name AS merchant_name,\n p.code AS platform_code, mfo.feature_code,\n mfo.limit_value, mfo.is_enabled, mfo.reason\nFROM merchant_feature_overrides mfo\nJOIN merchants m ON m.id = mfo.merchant_id\nJOIN platforms p ON p.id = mfo.platform_id\nORDER BY m.name, mfo.feature_code;" },
|
||||||
|
{ name: 'Store add-ons', sql: "SELECT sa.id, s.name AS store_name,\n ap.name AS addon_name, sa.status,\n sa.quantity, sa.domain_name,\n sa.period_start, sa.period_end\nFROM store_addons sa\nJOIN stores s ON s.id = sa.store_id\nJOIN addon_products ap ON ap.id = sa.addon_product_id\nORDER BY sa.id DESC;" },
|
||||||
|
{ name: 'Stripe webhook events', sql: "SELECT swe.id, swe.event_id, swe.event_type,\n swe.status, swe.processed_at,\n s.name AS store_name, swe.error_message\nFROM stripe_webhook_events swe\nLEFT JOIN stores s ON s.id = swe.store_id\nORDER BY swe.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── OMS ──
|
||||||
|
{
|
||||||
|
label: 'OMS',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'Orders',
|
||||||
|
items: [
|
||||||
|
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Order item exceptions', sql: "SELECT oie.id, o.order_number,\n oie.original_product_name, oie.original_gtin,\n oie.exception_type, oie.status,\n oie.resolved_at, oie.created_at\nFROM order_item_exceptions oie\nJOIN order_items oi ON oi.id = oie.order_item_id\nJOIN orders o ON o.id = oi.order_id\nORDER BY oie.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Invoice settings', sql: "SELECT sis.id, s.name AS store_name,\n sis.merchant_name, sis.vat_number,\n sis.is_vat_registered, sis.invoice_prefix,\n sis.invoice_next_number, sis.default_vat_rate\nFROM store_invoice_settings sis\nJOIN stores s ON s.id = sis.store_id\nORDER BY s.name;" },
|
||||||
|
{ name: 'Customer order stats', sql: "SELECT cos.id, s.name AS store_name,\n c.email AS customer_email, cos.total_orders,\n cos.total_spent_cents, cos.first_order_date,\n cos.last_order_date\nFROM customer_order_stats cos\nJOIN stores s ON s.id = cos.store_id\nJOIN customers c ON c.id = cos.customer_id\nORDER BY cos.total_spent_cents DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Cart',
|
||||||
|
items: [
|
||||||
|
{ name: 'Cart items', sql: "SELECT ci.id, s.name AS store_name,\n p.store_sku, ci.session_id,\n ci.quantity, ci.price_at_add_cents,\n ci.created_at\nFROM cart_items ci\nJOIN stores s ON s.id = ci.store_id\nJOIN products p ON p.id = ci.product_id\nORDER BY ci.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Catalog',
|
||||||
|
items: [
|
||||||
|
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Inventory',
|
||||||
|
items: [
|
||||||
|
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||||
|
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Marketplace',
|
category: 'Marketplace',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Import errors', sql: "SELECT mie.id, mij.marketplace,\n mie.row_number, mie.identifier,\n mie.error_type, mie.error_message\nFROM marketplace_import_errors mie\nJOIN marketplace_import_jobs mij ON mij.id = mie.import_job_id\nORDER BY mie.created_at DESC\nLIMIT 100;" },
|
||||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Product translations', sql: "SELECT mpt.id, mp.gtin,\n mpt.language, mpt.title, mpt.url_slug\nFROM marketplace_product_translations mpt\nJOIN marketplace_products mp ON mp.id = mpt.marketplace_product_id\nORDER BY mpt.id DESC\nLIMIT 50;" },
|
||||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Letzshop credentials', sql: "SELECT slc.id, s.name AS store_name,\n slc.api_endpoint, slc.auto_sync_enabled,\n slc.sync_interval_minutes, slc.last_sync_at,\n slc.last_sync_status\nFROM store_letzshop_credentials slc\nJOIN stores s ON s.id = slc.store_id\nORDER BY s.name;" },
|
||||||
|
{ name: 'Sync logs', sql: "SELECT sl.id, s.name AS store_name,\n sl.operation_type, sl.direction, sl.status,\n sl.records_processed, sl.records_failed,\n sl.duration_seconds, sl.triggered_by\nFROM letzshop_sync_logs sl\nJOIN stores s ON s.id = sl.store_id\nORDER BY sl.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Historical import jobs', sql: "SELECT hij.id, s.name AS store_name,\n hij.status, hij.current_phase,\n hij.orders_imported, hij.orders_skipped,\n hij.products_matched, hij.products_not_found\nFROM letzshop_historical_import_jobs hij\nJOIN stores s ON s.id = hij.store_id\nORDER BY hij.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Loyalty ──
|
||||||
|
{
|
||||||
|
label: 'Loyalty',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'Loyalty',
|
||||||
|
items: [
|
||||||
|
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||||
|
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||||
|
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||||
|
{ name: 'Apple device registrations', sql: "SELECT adr.id, lc.card_number,\n adr.device_library_identifier,\n adr.push_token, adr.created_at\nFROM apple_device_registrations adr\nJOIN loyalty_cards lc ON lc.id = adr.card_id\nORDER BY adr.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Merchant loyalty settings', sql: "SELECT mls.id, m.name AS merchant_name,\n mls.staff_pin_policy,\n mls.allow_self_enrollment,\n mls.allow_void_transactions,\n mls.allow_cross_location_redemption,\n mls.require_order_reference\nFROM merchant_loyalty_settings mls\nJOIN merchants m ON m.id = mls.merchant_id\nORDER BY m.name;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hosting ──
|
||||||
|
{
|
||||||
|
label: 'Hosting',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'Hosting',
|
||||||
|
items: [
|
||||||
|
{ name: 'Hosted sites', sql: "SELECT hs.id, s.name AS store_name,\n hs.business_name, hs.status,\n hs.contact_email, hs.live_domain,\n hs.went_live_at, hs.created_at\nFROM hosted_sites hs\nLEFT JOIN stores s ON s.id = hs.store_id\nORDER BY hs.created_at DESC;" },
|
||||||
|
{ name: 'Client services', sql: "SELECT cs.id, hs.business_name,\n cs.service_type, cs.name, cs.status,\n cs.billing_period, cs.price_cents,\n cs.domain_name, cs.expires_at\nFROM client_services cs\nJOIN hosted_sites hs ON hs.id = cs.hosted_site_id\nORDER BY hs.business_name, cs.service_type;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Prospecting',
|
||||||
|
items: [
|
||||||
|
{ name: 'Prospects', sql: "SELECT id, channel, business_name,\n domain_name, status, source,\n city, country, created_at\nFROM prospects\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Prospect contacts', sql: "SELECT pc.id, p.business_name,\n pc.contact_type, pc.value, pc.label,\n pc.is_primary, pc.is_validated\nFROM prospect_contacts pc\nJOIN prospects p ON p.id = pc.prospect_id\nORDER BY pc.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Tech profiles', sql: "SELECT tp.id, p.business_name,\n tp.cms, tp.cms_version, tp.server,\n tp.hosting_provider, tp.ecommerce_platform,\n tp.has_valid_cert\nFROM prospect_tech_profiles tp\nJOIN prospects p ON p.id = tp.prospect_id\nORDER BY tp.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Performance profiles', sql: "SELECT pp.id, p.business_name,\n pp.performance_score, pp.accessibility_score,\n pp.seo_score, pp.is_mobile_friendly,\n pp.total_bytes, pp.total_requests\nFROM prospect_performance_profiles pp\nJOIN prospects p ON p.id = pp.prospect_id\nORDER BY pp.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Interactions', sql: "SELECT pi.id, p.business_name,\n pi.interaction_type, pi.subject,\n pi.outcome, pi.next_action,\n pi.next_action_date, pi.created_at\nFROM prospect_interactions pi\nJOIN prospects p ON p.id = pi.prospect_id\nORDER BY pi.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Scan jobs', sql: "SELECT id, job_type, status,\n total_items, processed_items, failed_items,\n started_at, completed_at\nFROM prospect_scan_jobs\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Security audits', sql: "SELECT psa.id, p.business_name,\n psa.score, psa.grade,\n psa.findings_count_critical,\n psa.findings_count_high,\n psa.has_https, psa.has_valid_ssl\nFROM prospect_security_audits psa\nJOIN prospects p ON p.id = psa.prospect_id\nORDER BY psa.created_at DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Prospect scores', sql: "SELECT ps.id, p.business_name,\n ps.score, ps.lead_tier,\n ps.technical_health_score,\n ps.modernity_score,\n ps.business_value_score,\n ps.engagement_score\nFROM prospect_scores ps\nJOIN prospects p ON p.id = ps.prospect_id\nORDER BY ps.score DESC\nLIMIT 50;" },
|
||||||
|
{ name: 'Campaign templates', sql: "SELECT id, name, lead_type,\n channel, language, is_active\nFROM campaign_templates\nORDER BY lead_type, channel;" },
|
||||||
|
{ name: 'Campaign sends', sql: "SELECT cs.id, ct.name AS template_name,\n p.business_name, cs.channel,\n cs.status, cs.sent_at\nFROM campaign_sends cs\nJOIN campaign_templates ct ON ct.id = cs.template_id\nJOIN prospects p ON p.id = cs.prospect_id\nORDER BY cs.created_at DESC\nLIMIT 50;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal ──
|
||||||
|
{
|
||||||
|
label: 'Internal',
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
category: 'System',
|
||||||
|
items: [
|
||||||
|
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||||
|
{ name: 'Menu configs', sql: "SELECT amc.id, amc.frontend_type,\n p.code AS platform_code, u.email,\n amc.menu_item_id, amc.is_visible\nFROM admin_menu_configs amc\nLEFT JOIN platforms p ON p.id = amc.platform_id\nLEFT JOIN users u ON u.id = amc.user_id\nORDER BY amc.frontend_type, amc.menu_item_id;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Monitoring',
|
||||||
|
items: [
|
||||||
|
{ name: 'Capacity snapshots', sql: "SELECT id, snapshot_date,\n active_stores, total_products,\n total_orders_month, total_team_members,\n db_size_mb, avg_response_ms,\n peak_cpu_percent, peak_memory_percent\nFROM capacity_snapshots\nORDER BY snapshot_date DESC\nLIMIT 30;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Dev Tools',
|
||||||
|
items: [
|
||||||
|
{ name: 'Test runs', sql: "SELECT id, timestamp, status,\n total_tests, passed, failed, errors,\n coverage_percent, duration_seconds,\n git_branch\nFROM test_runs\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||||
|
{ name: 'Architecture scans', sql: "SELECT id, timestamp, validator_type,\n status, total_files, total_violations,\n errors, warnings, duration_seconds\nFROM architecture_scans\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||||
|
{ name: 'Architecture violations', sql: "SELECT av.id, av.rule_id, av.rule_name,\n av.severity, av.file_path, av.line_number,\n av.status, av.message\nFROM architecture_violations av\nORDER BY av.created_at DESC\nLIMIT 100;" },
|
||||||
|
]
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
get filteredPresetSections() {
|
||||||
|
const q = this.presetSearch.toLowerCase().trim();
|
||||||
|
if (!q) return this.presetSections;
|
||||||
|
|
||||||
|
const filtered = [];
|
||||||
|
for (const section of this.presetSections) {
|
||||||
|
const groups = [];
|
||||||
|
for (const group of section.groups) {
|
||||||
|
const items = group.items.filter(
|
||||||
|
item => item.name.toLowerCase().includes(q)
|
||||||
|
|| group.category.toLowerCase().includes(q)
|
||||||
|
|| section.label.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
if (items.length > 0) {
|
||||||
|
groups.push({ ...group, items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groups.length > 0) {
|
||||||
|
filtered.push({ ...section, groups });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
|
||||||
toggleCategory(category) {
|
toggleCategory(category) {
|
||||||
this.expandedCategories[category] = !this.expandedCategories[category];
|
this.expandedCategories[category] = !this.expandedCategories[category];
|
||||||
},
|
},
|
||||||
|
|
||||||
isCategoryExpanded(category) {
|
isCategoryExpanded(category) {
|
||||||
|
if (this.presetSearch.trim()) return true;
|
||||||
return this.expandedCategories[category] || false;
|
return this.expandedCategories[category] || false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,16 @@
|
|||||||
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||||
</button>
|
</button>
|
||||||
<div x-show="showPresets" x-collapse class="mt-3">
|
<div x-show="showPresets" x-collapse class="mt-3">
|
||||||
<template x-for="group in presetQueries" :key="group.category">
|
<!-- Search filter -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="text" x-model="presetSearch" placeholder="Filter presets..."
|
||||||
|
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1.5 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
<template x-for="section in filteredPresetSections" :key="section.label">
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
|
||||||
|
x-text="section.label"></div>
|
||||||
|
<template x-for="group in section.groups" :key="group.category">
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<button @click="toggleCategory(group.category)"
|
<button @click="toggleCategory(group.category)"
|
||||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
@@ -43,6 +52,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="presetSearch && filteredPresetSections.length === 0"
|
||||||
|
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Saved Queries -->
|
<!-- Saved Queries -->
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# app/modules/dev_tools/tests/unit/test_domain_health_service.py
|
||||||
|
"""Unit tests for domain_health_service."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.dev_tools.services.domain_health_service import _entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestEntry:
|
||||||
|
"""Tests for the _entry helper function."""
|
||||||
|
|
||||||
|
def test_entry_pass(self):
|
||||||
|
"""Passing entry has status 'pass'."""
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
store_code = "ACME"
|
||||||
|
subdomain = "acme"
|
||||||
|
|
||||||
|
result = _entry(
|
||||||
|
domain="acme.rewardflow.lu",
|
||||||
|
entry_type="custom subdomain",
|
||||||
|
platform_code="RF",
|
||||||
|
expected_store=FakeStore(),
|
||||||
|
resolved_store=FakeStore(),
|
||||||
|
passed=True,
|
||||||
|
note="",
|
||||||
|
)
|
||||||
|
assert result["status"] == "pass"
|
||||||
|
assert result["domain"] == "acme.rewardflow.lu"
|
||||||
|
assert result["type"] == "custom subdomain"
|
||||||
|
assert result["platform_code"] == "RF"
|
||||||
|
assert result["expected_store"] == "ACME"
|
||||||
|
assert result["resolved_store"] == "ACME"
|
||||||
|
|
||||||
|
def test_entry_fail(self):
|
||||||
|
"""Failing entry has status 'fail' and includes note."""
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
store_code = "ACME"
|
||||||
|
subdomain = "acme"
|
||||||
|
|
||||||
|
result = _entry(
|
||||||
|
domain="acme.rewardflow.lu",
|
||||||
|
entry_type="custom subdomain",
|
||||||
|
platform_code="RF",
|
||||||
|
expected_store=FakeStore(),
|
||||||
|
resolved_store=None,
|
||||||
|
passed=False,
|
||||||
|
note="custom_subdomain lookup failed",
|
||||||
|
)
|
||||||
|
assert result["status"] == "fail"
|
||||||
|
assert result["resolved_store"] is None
|
||||||
|
assert result["note"] == "custom_subdomain lookup failed"
|
||||||
|
|
||||||
|
def test_entry_none_stores(self):
|
||||||
|
"""Entry handles None expected/resolved stores."""
|
||||||
|
result = _entry(
|
||||||
|
domain="test.example.com",
|
||||||
|
entry_type="custom domain",
|
||||||
|
platform_code=None,
|
||||||
|
expected_store=None,
|
||||||
|
resolved_store=None,
|
||||||
|
passed=False,
|
||||||
|
note="not found",
|
||||||
|
)
|
||||||
|
assert result["expected_store"] is None
|
||||||
|
assert result["resolved_store"] is None
|
||||||
|
assert result["platform_code"] is None
|
||||||
193
app/modules/dev_tools/tests/unit/test_isolation_audit_service.py
Normal file
193
app/modules/dev_tools/tests/unit/test_isolation_audit_service.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# app/modules/dev_tools/tests/unit/test_isolation_audit_service.py
|
||||||
|
"""Unit tests for isolation_audit_service."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.dev_tools.services.isolation_audit_service import (
|
||||||
|
_check_contact_inheritance,
|
||||||
|
_check_language_config,
|
||||||
|
_check_locale_fallback,
|
||||||
|
_check_merchant_active,
|
||||||
|
_check_merchant_domain_primary,
|
||||||
|
_check_theme_fallback,
|
||||||
|
_finding,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store(**kwargs):
|
||||||
|
"""Create a minimal fake store for testing."""
|
||||||
|
defaults = {
|
||||||
|
"contact_email": None,
|
||||||
|
"contact_phone": None,
|
||||||
|
"website": None,
|
||||||
|
"business_address": None,
|
||||||
|
"tax_number": None,
|
||||||
|
"merchant": None,
|
||||||
|
"store_platforms": [],
|
||||||
|
"domains": [],
|
||||||
|
"store_theme": None,
|
||||||
|
"storefront_locale": None,
|
||||||
|
"storefront_languages": None,
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_merchant(**kwargs):
|
||||||
|
defaults = {"name": "TestMerchant", "is_active": True}
|
||||||
|
defaults.update({k: None for k in ["contact_email", "contact_phone", "website", "business_address", "tax_number"]})
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestFinding:
|
||||||
|
"""Tests for the _finding helper."""
|
||||||
|
|
||||||
|
def test_finding_structure(self):
|
||||||
|
result = _finding(
|
||||||
|
check="test",
|
||||||
|
check_label="Test",
|
||||||
|
risk="high",
|
||||||
|
resolved_value="val",
|
||||||
|
source="store",
|
||||||
|
source_label="Store value",
|
||||||
|
is_explicit=True,
|
||||||
|
note="a note",
|
||||||
|
)
|
||||||
|
assert result["check"] == "test"
|
||||||
|
assert result["risk"] == "high"
|
||||||
|
assert result["is_explicit"] is True
|
||||||
|
assert result["note"] == "a note"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestCheckContactInheritance:
|
||||||
|
"""Tests for _check_contact_inheritance."""
|
||||||
|
|
||||||
|
def test_no_findings_when_store_has_values(self):
|
||||||
|
store = _make_store(
|
||||||
|
contact_email="shop@test.com",
|
||||||
|
contact_phone="+352123",
|
||||||
|
website="https://test.com",
|
||||||
|
business_address="123 Main St",
|
||||||
|
tax_number="LU12345",
|
||||||
|
merchant=_make_merchant(),
|
||||||
|
)
|
||||||
|
assert _check_contact_inheritance(store) == []
|
||||||
|
|
||||||
|
def test_critical_when_inheriting_from_merchant(self):
|
||||||
|
merchant = _make_merchant(contact_email="merchant@test.com")
|
||||||
|
store = _make_store(merchant=merchant)
|
||||||
|
findings = _check_contact_inheritance(store)
|
||||||
|
email_findings = [f for f in findings if f["check"] == "contact_email"]
|
||||||
|
assert len(email_findings) == 1
|
||||||
|
assert email_findings[0]["risk"] == "critical"
|
||||||
|
assert email_findings[0]["source"] == "merchant"
|
||||||
|
|
||||||
|
def test_critical_when_no_value_anywhere(self):
|
||||||
|
store = _make_store(merchant=_make_merchant())
|
||||||
|
findings = _check_contact_inheritance(store)
|
||||||
|
email_findings = [f for f in findings if f["check"] == "contact_email"]
|
||||||
|
assert len(email_findings) == 1
|
||||||
|
assert email_findings[0]["source"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestCheckMerchantActive:
|
||||||
|
"""Tests for _check_merchant_active."""
|
||||||
|
|
||||||
|
def test_no_finding_when_active(self):
|
||||||
|
store = _make_store(merchant=_make_merchant(is_active=True))
|
||||||
|
assert _check_merchant_active(store) == []
|
||||||
|
|
||||||
|
def test_finding_when_inactive(self):
|
||||||
|
store = _make_store(merchant=_make_merchant(is_active=False))
|
||||||
|
findings = _check_merchant_active(store)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["risk"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestCheckMerchantDomainPrimary:
|
||||||
|
"""Tests for _check_merchant_domain_primary."""
|
||||||
|
|
||||||
|
def test_no_finding_single_primary(self):
|
||||||
|
domains = [SimpleNamespace(domain="test.com", is_primary=True, is_active=True)]
|
||||||
|
assert _check_merchant_domain_primary(domains) == []
|
||||||
|
|
||||||
|
def test_finding_multiple_primaries(self):
|
||||||
|
domains = [
|
||||||
|
SimpleNamespace(domain="a.com", is_primary=True, is_active=True),
|
||||||
|
SimpleNamespace(domain="b.com", is_primary=True, is_active=True),
|
||||||
|
]
|
||||||
|
findings = _check_merchant_domain_primary(domains)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["risk"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestCheckThemeFallback:
|
||||||
|
"""Tests for _check_theme_fallback."""
|
||||||
|
|
||||||
|
def test_no_finding_with_active_theme(self):
|
||||||
|
store = _make_store(store_theme=SimpleNamespace(is_active=True))
|
||||||
|
assert _check_theme_fallback(store) == []
|
||||||
|
|
||||||
|
def test_finding_no_theme(self):
|
||||||
|
store = _make_store(store_theme=None)
|
||||||
|
findings = _check_theme_fallback(store)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["risk"] == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestCheckLocaleFallback:
|
||||||
|
"""Tests for _check_locale_fallback."""
|
||||||
|
|
||||||
|
def test_no_finding_with_locale(self):
|
||||||
|
store = _make_store(storefront_locale="de-LU")
|
||||||
|
assert _check_locale_fallback(store, "fr-LU") == []
|
||||||
|
|
||||||
|
def test_finding_platform_default(self):
|
||||||
|
store = _make_store(storefront_locale=None)
|
||||||
|
findings = _check_locale_fallback(store, "de-LU")
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["resolved_value"] == "de-LU"
|
||||||
|
assert findings[0]["source"] == "platform_default"
|
||||||
|
|
||||||
|
def test_finding_hardcoded_fallback(self):
|
||||||
|
store = _make_store(storefront_locale=None)
|
||||||
|
findings = _check_locale_fallback(store, None)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["resolved_value"] == "fr-LU"
|
||||||
|
assert findings[0]["source"] == "hardcoded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.dev
|
||||||
|
class TestCheckLanguageConfig:
|
||||||
|
"""Tests for _check_language_config."""
|
||||||
|
|
||||||
|
def test_no_finding_custom_languages(self):
|
||||||
|
store = _make_store(storefront_languages=["fr", "en"])
|
||||||
|
assert _check_language_config(store) == []
|
||||||
|
|
||||||
|
def test_finding_default_languages(self):
|
||||||
|
store = _make_store(storefront_languages=["fr", "de", "en", "lb"])
|
||||||
|
findings = _check_language_config(store)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["risk"] == "medium"
|
||||||
|
|
||||||
|
def test_finding_none_languages(self):
|
||||||
|
store = _make_store(storefront_languages=None)
|
||||||
|
findings = _check_language_config(store)
|
||||||
|
assert len(findings) == 1
|
||||||
@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
|
|||||||
# Default currency for pricing
|
# Default currency for pricing
|
||||||
default_currency: str = "EUR"
|
default_currency: str = "EUR"
|
||||||
|
|
||||||
model_config = {"env_prefix": "HOSTING_"}
|
model_config = {"env_prefix": "HOSTING_", "env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
# Export for auto-discovery
|
# Export for auto-discovery
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
@@ -22,13 +23,90 @@ from app.modules.hosting.schemas.hosted_site import (
|
|||||||
HostedSiteUpdate,
|
HostedSiteUpdate,
|
||||||
SendProposalRequest,
|
SendProposalRequest,
|
||||||
)
|
)
|
||||||
|
from app.modules.hosting.schemas.template import TemplateListResponse, TemplateResponse
|
||||||
from app.modules.hosting.services.hosted_site_service import hosted_site_service
|
from app.modules.hosting.services.hosted_site_service import hosted_site_service
|
||||||
|
from app.modules.hosting.services.poc_builder_service import poc_builder_service
|
||||||
|
from app.modules.hosting.services.template_service import template_service
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
|
||||||
router = APIRouter(prefix="/sites")
|
router = APIRouter(prefix="/sites")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=TemplateListResponse)
|
||||||
|
def list_templates(
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""List available industry templates for POC site generation."""
|
||||||
|
templates = template_service.list_templates()
|
||||||
|
return TemplateListResponse(
|
||||||
|
templates=[TemplateResponse(**t) for t in templates],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewUrlResponse(BaseModel):
|
||||||
|
"""Response with signed preview URL."""
|
||||||
|
|
||||||
|
preview_url: str
|
||||||
|
expires_in_hours: int = 24
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
|
||||||
|
def get_preview_url(
|
||||||
|
site_id: int = Path(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""Generate a signed preview URL for a hosted site."""
|
||||||
|
from app.core.preview_token import create_preview_token
|
||||||
|
|
||||||
|
site = hosted_site_service.get_by_id(db, site_id)
|
||||||
|
store = site.store
|
||||||
|
subdomain = store.subdomain or store.store_code
|
||||||
|
token = create_preview_token(store.id, subdomain, site.id)
|
||||||
|
return PreviewUrlResponse(
|
||||||
|
preview_url=f"/storefront/{subdomain}/?_preview={token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildPocRequest(BaseModel):
|
||||||
|
"""Request to build a POC site from prospect + template."""
|
||||||
|
|
||||||
|
prospect_id: int
|
||||||
|
template_id: str
|
||||||
|
merchant_id: int | None = None
|
||||||
|
site_id: int | None = None # If set, populate existing site instead of creating new one
|
||||||
|
|
||||||
|
|
||||||
|
class BuildPocResponse(BaseModel):
|
||||||
|
"""Response from POC builder."""
|
||||||
|
|
||||||
|
hosted_site_id: int
|
||||||
|
store_id: int
|
||||||
|
pages_created: int
|
||||||
|
theme_applied: bool
|
||||||
|
template_id: str
|
||||||
|
subdomain: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/poc/build", response_model=BuildPocResponse)
|
||||||
|
def build_poc(
|
||||||
|
data: BuildPocRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: UserContext = Depends(get_current_admin_api),
|
||||||
|
):
|
||||||
|
"""Build a POC site from prospect data + industry template."""
|
||||||
|
result = poc_builder_service.build_poc(
|
||||||
|
db,
|
||||||
|
prospect_id=data.prospect_id,
|
||||||
|
template_id=data.template_id,
|
||||||
|
merchant_id=data.merchant_id,
|
||||||
|
site_id=data.site_id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return BuildPocResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(site) -> HostedSiteResponse:
|
def _to_response(site) -> HostedSiteResponse:
|
||||||
"""Convert a hosted site model to response schema."""
|
"""Convert a hosted site model to response schema."""
|
||||||
return HostedSiteResponse(
|
return HostedSiteResponse(
|
||||||
@@ -96,17 +174,6 @@ def create_site(
|
|||||||
return _to_response(site)
|
return _to_response(site)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
|
|
||||||
def create_from_prospect(
|
|
||||||
prospect_id: int = Path(...),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_admin: UserContext = Depends(get_current_admin_api),
|
|
||||||
):
|
|
||||||
"""Create a hosted site pre-filled from prospect data."""
|
|
||||||
site = hosted_site_service.create_from_prospect(db, prospect_id)
|
|
||||||
db.commit()
|
|
||||||
return _to_response(site)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{site_id}", response_model=HostedSiteResponse)
|
@router.put("/{site_id}", response_model=HostedSiteResponse)
|
||||||
def update_site(
|
def update_site(
|
||||||
|
|||||||
@@ -2,45 +2,73 @@
|
|||||||
"""
|
"""
|
||||||
Hosting Public Page Routes.
|
Hosting Public Page Routes.
|
||||||
|
|
||||||
Public-facing routes for POC site viewing:
|
POC site preview via signed URL redirect to the storefront.
|
||||||
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
|
The StorefrontAccessMiddleware validates the preview token and
|
||||||
|
allows rendering without an active subscription.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Request
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.templates_config import templates
|
from app.core.preview_token import create_preview_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/hosting/sites/{site_id}/preview",
|
"/hosting/sites/{site_id}/preview",
|
||||||
response_class=HTMLResponse,
|
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
async def poc_site_viewer(
|
async def poc_site_viewer(
|
||||||
request: Request,
|
|
||||||
site_id: int = Path(..., description="Hosted Site ID"),
|
site_id: int = Path(..., description="Hosted Site ID"),
|
||||||
|
page: str = Query("homepage", description="Page slug to preview"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Render POC site viewer with HostWizard preview banner."""
|
"""Redirect to storefront with signed preview token.
|
||||||
|
|
||||||
|
Generates a time-limited JWT and redirects to the store's
|
||||||
|
storefront URL. The StorefrontAccessMiddleware validates the
|
||||||
|
token and bypasses the subscription check.
|
||||||
|
"""
|
||||||
from app.modules.hosting.models import HostedSite, HostedSiteStatus
|
from app.modules.hosting.models import HostedSite, HostedSiteStatus
|
||||||
|
|
||||||
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
|
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
|
||||||
|
|
||||||
# Only allow viewing for poc_ready or proposal_sent sites
|
if not site or site.status not in (
|
||||||
if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
|
HostedSiteStatus.POC_READY,
|
||||||
|
HostedSiteStatus.PROPOSAL_SENT,
|
||||||
|
HostedSiteStatus.ACCEPTED,
|
||||||
|
):
|
||||||
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
|
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
|
||||||
|
|
||||||
context = {
|
store = site.store
|
||||||
"request": request,
|
if not store:
|
||||||
"site": site,
|
return HTMLResponse(content="<h1>Store not found</h1>", status_code=404)
|
||||||
"store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
|
|
||||||
}
|
# Generate signed preview token — use subdomain for URL routing
|
||||||
return templates.TemplateResponse(
|
subdomain = store.subdomain or store.store_code
|
||||||
"hosting/public/poc-viewer.html",
|
token = create_preview_token(store.id, subdomain, site.id)
|
||||||
context,
|
|
||||||
|
# Get platform code for dev-mode URL prefix
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.modules.tenancy.models import StorePlatform
|
||||||
|
|
||||||
|
store_platform = (
|
||||||
|
db.query(StorePlatform)
|
||||||
|
.filter(StorePlatform.store_id == store.id)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# In dev mode, storefront needs /platforms/{code}/ prefix
|
||||||
|
if settings.debug and store_platform and store_platform.platform:
|
||||||
|
platform_code = store_platform.platform.code
|
||||||
|
base_url = f"/platforms/{platform_code}/storefront/{subdomain}"
|
||||||
|
else:
|
||||||
|
base_url = f"/storefront/{subdomain}"
|
||||||
|
|
||||||
|
# Append page slug — storefront needs /{slug} (root has no catch-all)
|
||||||
|
base_url += f"/{page}"
|
||||||
|
|
||||||
|
return RedirectResponse(f"{base_url}?_preview={token}", status_code=302)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user