refactor: complete module-driven architecture migration

This commit completes the migration to a fully module-driven architecture:

## Models Migration
- Moved all domain models from models/database/ to their respective modules:
  - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc.
  - cms: MediaFile, VendorTheme
  - messaging: Email, VendorEmailSettings, VendorEmailTemplate
  - core: AdminMenuConfig
- models/database/ now only contains Base and TimestampMixin (infrastructure)

## Schemas Migration
- Moved all domain schemas from models/schema/ to their respective modules:
  - tenancy: company, vendor, admin, team, vendor_domain
  - cms: media, image, vendor_theme
  - messaging: email
- models/schema/ now only contains base.py and auth.py (infrastructure)

## Routes Migration
- Moved admin routes from app/api/v1/admin/ to modules:
  - menu_config.py -> core module
  - modules.py -> tenancy module
  - module_config.py -> tenancy module
- app/api/v1/admin/ now only aggregates auto-discovered module routes

## Menu System
- Implemented module-driven menu system with MenuDiscoveryService
- Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT
- Added MenuItemDefinition and MenuSectionDefinition dataclasses
- Each module now defines its own menu items in definition.py
- MenuService integrates with MenuDiscoveryService for template rendering

## Documentation
- Updated docs/architecture/models-structure.md
- Updated docs/architecture/menu-management.md
- Updated architecture validation rules for new exceptions

## Architecture Validation
- Updated MOD-019 rule to allow base.py in models/schema/
- Created core module exceptions.py and schemas/ directory
- All validation errors resolved (only warnings remain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 21:02:56 +01:00
parent 09d7d282c6
commit d7a0ff8818
307 changed files with 5536 additions and 3826 deletions

View File

@@ -536,7 +536,8 @@ module_rules:
EXCEPTIONS (allowed in legacy): EXCEPTIONS (allowed in legacy):
- __init__.py (re-exports for backwards compatibility) - __init__.py (re-exports for backwards compatibility)
- auth.py (core authentication schemas) - base.py (base schema classes - infrastructure)
- auth.py (core authentication schemas - cross-cutting)
- Files with # noqa: mod-019 comment - Files with # noqa: mod-019 comment
WHY THIS MATTERS: WHY THIS MATTERS:
@@ -548,4 +549,5 @@ module_rules:
- "models/schema/*.py" - "models/schema/*.py"
exceptions: exceptions:
- "__init__.py" - "__init__.py"
- "base.py"
- "auth.py" - "auth.py"

View File

@@ -40,7 +40,7 @@ print("=" * 70)
# ADMIN MODELS # ADMIN MODELS
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
try: try:
from models.database.admin import ( from app.modules.tenancy.models import (
AdminAuditLog, AdminAuditLog,
AdminNotification, AdminNotification,
AdminSession, AdminSession,
@@ -61,7 +61,7 @@ except ImportError as e:
# USER MODEL # USER MODEL
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
try: try:
from models.database.user import User from app.modules.tenancy.models import User
print(" ✓ User model imported") print(" ✓ User model imported")
except ImportError as e: except ImportError as e:
@@ -71,7 +71,7 @@ except ImportError as e:
# VENDOR MODELS # VENDOR MODELS
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
try: try:
from models.database.vendor import Role, Vendor, VendorUser from app.modules.tenancy.models import Role, Vendor, VendorUser
print(" ✓ Vendor models imported (3 models)") print(" ✓ Vendor models imported (3 models)")
print(" - Vendor") print(" - Vendor")
@@ -81,14 +81,14 @@ except ImportError as e:
print(f" ✗ Vendor models failed: {e}") print(f" ✗ Vendor models failed: {e}")
try: try:
from models.database.vendor_domain import VendorDomain from app.modules.tenancy.models import VendorDomain
print(" ✓ VendorDomain model imported") print(" ✓ VendorDomain model imported")
except ImportError as e: except ImportError as e:
print(f" ✗ VendorDomain model failed: {e}") print(f" ✗ VendorDomain model failed: {e}")
try: try:
from models.database.vendor_theme import VendorTheme from app.modules.cms.models import VendorTheme
print(" ✓ VendorTheme model imported") print(" ✓ VendorTheme model imported")
except ImportError as e: except ImportError as e:

View File

@@ -51,8 +51,8 @@ from app.modules.tenancy.exceptions import (
from app.modules.tenancy.services.vendor_service import vendor_service from app.modules.tenancy.services.vendor_service import vendor_service
from middleware.auth import AuthManager from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter from middleware.rate_limiter import RateLimiter
from models.database.user import User as UserModel from app.modules.tenancy.models import User as UserModel
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
from models.schema.auth import UserContext from models.schema.auth import UserContext
# Initialize dependencies # Initialize dependencies
@@ -381,7 +381,7 @@ def get_admin_with_platform_context(
InvalidTokenException: If platform admin token missing platform info InvalidTokenException: If platform admin token missing platform info
InsufficientPermissionsException: If platform access revoked InsufficientPermissionsException: If platform access revoked
""" """
from models.database.platform import Platform from app.modules.tenancy.models import Platform
# Get raw token for platform_id extraction # Get raw token for platform_id extraction
token, source = _get_token_from_request( token, source = _get_token_from_request(
@@ -553,7 +553,7 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"):
from app.modules.registry import get_menu_item_module from app.modules.registry import get_menu_item_module
from app.modules.service import module_service from app.modules.service import module_service
from app.modules.core.services.menu_service import menu_service from app.modules.core.services.menu_service import menu_service
from models.database.admin_menu_config import FrontendType as FT from app.modules.enums import FrontendType as FT
def _check_menu_access( def _check_menu_access(
request: Request, request: Request,

View File

@@ -2,16 +2,11 @@
""" """
Admin API router aggregation. Admin API router aggregation.
This module combines legacy admin routes with auto-discovered module routes. This module combines auto-discovered module routes for the admin API.
LEGACY ROUTES (defined in app/api/v1/admin/): All admin routes are now auto-discovered from modules:
- /menu-config/* - Navigation configuration (super admin) - tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains, modules, module_config
- /modules/* - Module management (super admin) - core: dashboard, settings, menu_config
- /module-config/* - Module settings (super admin)
AUTO-DISCOVERED MODULE ROUTES:
- tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains
- core: dashboard, settings
- messaging: messages, notifications, email-templates - messaging: messages, notifications, email-templates
- monitoring: logs, tasks, tests, code_quality, audit, platform-health - monitoring: logs, tasks, tests, code_quality, audit, platform-health
- billing: subscriptions, invoices, payments - billing: subscriptions, invoices, payments
@@ -30,44 +25,18 @@ IMPORTANT:
from fastapi import APIRouter from fastapi import APIRouter
# Import all admin routers (legacy routes that haven't been migrated to modules)
# NOTE: Migrated to modules (auto-discovered):
# - tenancy: auth, admin_users, users, companies, platforms, vendors, vendor_domains
# - core: dashboard, settings
# - messaging: messages, notifications, email_templates
# - monitoring: logs, tasks, tests, code_quality, audit, platform_health
# - cms: content_pages, images, media, vendor_themes
from . import (
menu_config,
module_config,
modules,
)
# Create admin router # Create admin router
router = APIRouter() router = APIRouter()
# ============================================================================
# Framework Config (remain in legacy - super admin only)
# ============================================================================
# Include menu configuration endpoints (super admin only)
router.include_router(menu_config.router, tags=["admin-menu-config"])
# Include module management endpoints (super admin only)
router.include_router(modules.router, tags=["admin-modules"])
# Include module configuration endpoints (super admin only)
router.include_router(module_config.router, tags=["admin-module-config"])
# ============================================================================ # ============================================================================
# Auto-discovered Module Routes # Auto-discovered Module Routes
# ============================================================================ # ============================================================================
# Routes from self-contained modules are auto-discovered and registered. # All routes from self-contained modules are auto-discovered and registered.
# Modules include: billing, inventory, orders, marketplace, cms, customers, # Legacy routes have been migrated to their respective modules:
# monitoring (logs, tasks, tests, code_quality, audit, platform_health), # - menu_config -> core module
# messaging (messages, notifications, email_templates) # - modules, module_config -> tenancy module
from app.modules.routes import get_admin_api_routes from app.modules.routes import get_admin_api_routes

View File

@@ -14,7 +14,7 @@ Database only stores visibility overrides (is_visible=False).
from enum import Enum from enum import Enum
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
class AdminMenuItem(str, Enum): class AdminMenuItem(str, Enum):

View File

@@ -39,7 +39,7 @@ from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.modules.billing.services.feature_service import feature_service from app.modules.billing.services.feature_service import feature_service
from app.modules.billing.models import FeatureCode from app.modules.billing.models import FeatureCode
from models.database.user import User from app.modules.tenancy.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -33,7 +33,7 @@ class DatabaseLogHandler(logging.Handler):
"""Emit a log record to the database.""" """Emit a log record to the database."""
try: try:
from app.core.database import SessionLocal from app.core.database import SessionLocal
from models.database.admin import ApplicationLog from app.modules.tenancy.models import ApplicationLog
# Skip if no database session available # Skip if no database session available
db = SessionLocal() db = SessionLocal()

View File

@@ -6,7 +6,7 @@ Presets define default color schemes, fonts, and layouts that vendors can choose
Each preset provides a complete theme configuration that can be customized further. Each preset provides a complete theme configuration that can be customized further.
""" """
from models.database.vendor_theme import VendorTheme from app.modules.cms.models import VendorTheme
THEME_PRESETS = { THEME_PRESETS = {
"default": { "default": {

View File

@@ -6,8 +6,8 @@ Defines the analytics module including its features, menu items,
route configurations, and self-contained module settings. route configurations, and self-contained module settings.
""" """
from app.modules.base import ModuleDefinition from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
def _get_vendor_api_router(): def _get_vendor_api_router():
@@ -45,6 +45,27 @@ analytics_module = ModuleDefinition(
"analytics", # Vendor analytics page "analytics", # Vendor analytics page
], ],
}, },
# New module-driven menu definitions
menus={
FrontendType.VENDOR: [
MenuSectionDefinition(
id="main",
label_key=None,
icon=None,
order=0,
is_collapsible=False,
items=[
MenuItemDefinition(
id="analytics",
label_key="analytics.menu.analytics",
icon="chart-bar",
route="/vendor/{vendor_code}/analytics",
order=20,
),
],
),
],
},
is_core=False, is_core=False,
# ========================================================================= # =========================================================================
# Self-Contained Module Configuration # Self-Contained Module Configuration

View File

@@ -25,7 +25,7 @@ from app.modules.analytics.schemas import (
VendorAnalyticsResponse, VendorAnalyticsResponse,
) )
from app.modules.billing.models import FeatureCode from app.modules.billing.models import FeatureCode
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter( router = APIRouter(
prefix="/analytics", prefix="/analytics",

View File

@@ -15,8 +15,8 @@ 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.core.utils.page_context import get_admin_context
from app.templates_config import templates from app.templates_config import templates
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service
from app.templates_config import templates from app.templates_config import templates
from models.database.user import User from app.modules.tenancy.models import User
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -24,8 +24,8 @@ from app.modules.inventory.models import Inventory
from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct
from app.modules.orders.models import Order from app.modules.orders.models import Order
from app.modules.catalog.models import Product from app.modules.catalog.models import Product
from models.database.user import User from app.modules.tenancy.models import User
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.modules.catalog.models import Product from app.modules.catalog.models import Product
from app.modules.billing.models import SubscriptionTier, VendorSubscription from app.modules.billing.models import SubscriptionTier, VendorSubscription
from models.database.vendor import VendorUser from app.modules.tenancy.models import VendorUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -47,6 +47,93 @@ if TYPE_CHECKING:
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
# =============================================================================
# Menu Item Definitions
# =============================================================================
@dataclass
class MenuItemDefinition:
"""
Definition of a single menu item within a section.
Attributes:
id: Unique identifier (e.g., "catalog.products", "orders.list")
label_key: i18n key for the menu item label
icon: Lucide icon name (e.g., "box", "shopping-cart")
route: URL path (can include placeholders like {vendor_code})
order: Sort order within section (lower = higher priority)
is_mandatory: If True, cannot be hidden by user preferences
requires_permission: Permission code required to see this item
badge_source: Key for dynamic badge count (e.g., "pending_orders_count")
is_super_admin_only: Only visible to super admins (admin frontend only)
Example:
MenuItemDefinition(
id="catalog.products",
label_key="catalog.menu.products",
icon="box",
route="/admin/catalog/products",
order=10,
is_mandatory=True
)
"""
id: str
label_key: str
icon: str
route: str
order: int = 100
is_mandatory: bool = False
requires_permission: str | None = None
badge_source: str | None = None
is_super_admin_only: bool = False
@dataclass
class MenuSectionDefinition:
"""
Definition of a menu section containing related menu items.
Sections group related menu items together in the sidebar.
A section can be collapsed/expanded by the user.
Attributes:
id: Unique section identifier (e.g., "catalog", "orders")
label_key: i18n key for section header (None for headerless sections)
icon: Lucide icon name for section (optional)
order: Sort order among sections (lower = higher priority)
items: List of menu items in this section
is_super_admin_only: Only visible to super admins
is_collapsible: Whether section can be collapsed
Example:
MenuSectionDefinition(
id="catalog",
label_key="catalog.menu.section",
icon="package",
order=20,
items=[
MenuItemDefinition(
id="catalog.products",
label_key="catalog.menu.products",
icon="box",
route="/admin/catalog/products",
order=10
),
]
)
"""
id: str
label_key: str | None
icon: str | None = None
order: int = 100
items: list[MenuItemDefinition] = field(default_factory=list)
is_super_admin_only: bool = False
is_collapsible: bool = True
@dataclass @dataclass
class ScheduledTask: class ScheduledTask:
""" """
@@ -190,6 +277,14 @@ class ModuleDefinition:
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict) menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
permissions: list[str] = field(default_factory=list) permissions: list[str] = field(default_factory=list)
# =========================================================================
# Menu Definitions (Module-Driven Menus)
# =========================================================================
# NEW: Full menu definitions per frontend type. When set, these take
# precedence over menu_items for menu rendering. This enables modules
# to fully define their own menu structure with icons, routes, and labels.
menus: dict[FrontendType, list[MenuSectionDefinition]] = field(default_factory=dict)
# ========================================================================= # =========================================================================
# Classification # Classification
# ========================================================================= # =========================================================================
@@ -235,15 +330,15 @@ class ModuleDefinition:
scheduled_tasks: list[ScheduledTask] = field(default_factory=list) scheduled_tasks: list[ScheduledTask] = field(default_factory=list)
# ========================================================================= # =========================================================================
# Menu Item Methods # Menu Item Methods (Legacy - uses menu_items dict of IDs)
# ========================================================================= # =========================================================================
def get_menu_items(self, frontend_type: FrontendType) -> list[str]: def get_menu_items(self, frontend_type: FrontendType) -> list[str]:
"""Get menu item IDs for a specific frontend type.""" """Get menu item IDs for a specific frontend type (legacy)."""
return self.menu_items.get(frontend_type, []) return self.menu_items.get(frontend_type, [])
def get_all_menu_items(self) -> set[str]: def get_all_menu_items(self) -> set[str]:
"""Get all menu item IDs across all frontend types.""" """Get all menu item IDs across all frontend types (legacy)."""
all_items = set() all_items = set()
for items in self.menu_items.values(): for items in self.menu_items.values():
all_items.update(items) all_items.update(items)
@@ -253,6 +348,50 @@ class ModuleDefinition:
"""Check if this module provides a specific menu item.""" """Check if this module provides a specific menu item."""
return menu_item_id in self.get_all_menu_items() return menu_item_id in self.get_all_menu_items()
# =========================================================================
# Menu Definition Methods (New - uses menus dict of full definitions)
# =========================================================================
def get_menu_sections(self, frontend_type: FrontendType) -> list[MenuSectionDefinition]:
"""
Get menu section definitions for a specific frontend type.
Args:
frontend_type: The frontend type to get menus for
Returns:
List of MenuSectionDefinition objects, sorted by order
"""
sections = self.menus.get(frontend_type, [])
return sorted(sections, key=lambda s: s.order)
def get_all_menu_definitions(self) -> dict[FrontendType, list[MenuSectionDefinition]]:
"""
Get all menu definitions for all frontend types.
Returns:
Dict mapping FrontendType to list of MenuSectionDefinition
"""
return self.menus
def has_menus_for_frontend(self, frontend_type: FrontendType) -> bool:
"""Check if this module has menu definitions for a frontend type."""
return frontend_type in self.menus and len(self.menus[frontend_type]) > 0
def get_mandatory_menu_item_ids(self, frontend_type: FrontendType) -> set[str]:
"""
Get IDs of all mandatory menu items for a frontend type.
Returns:
Set of menu item IDs that are marked as is_mandatory=True
"""
mandatory_ids = set()
for section in self.menus.get(frontend_type, []):
for item in section.items:
if item.is_mandatory:
mandatory_ids.add(item.id)
return mandatory_ids
# ========================================================================= # =========================================================================
# Feature Methods # Feature Methods
# ========================================================================= # =========================================================================

View File

@@ -6,8 +6,8 @@ Defines the billing module including its features, menu items,
route configurations, and scheduled tasks. route configurations, and scheduled tasks.
""" """
from app.modules.base import ModuleDefinition, ScheduledTask from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, ScheduledTask
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
def _get_admin_router(): def _get_admin_router():
@@ -53,6 +53,72 @@ billing_module = ModuleDefinition(
"invoices", # Vendor invoice history "invoices", # Vendor invoice history
], ],
}, },
# New module-driven menu definitions
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="billing",
label_key="billing.menu.billing_subscriptions",
icon="credit-card",
order=50,
items=[
MenuItemDefinition(
id="subscription-tiers",
label_key="billing.menu.subscription_tiers",
icon="tag",
route="/admin/subscription-tiers",
order=10,
),
MenuItemDefinition(
id="subscriptions",
label_key="billing.menu.vendor_subscriptions",
icon="credit-card",
route="/admin/subscriptions",
order=20,
),
MenuItemDefinition(
id="billing-history",
label_key="billing.menu.billing_history",
icon="document-text",
route="/admin/billing-history",
order=30,
),
],
),
],
FrontendType.VENDOR: [
MenuSectionDefinition(
id="sales",
label_key="billing.menu.sales_orders",
icon="currency-euro",
order=20,
items=[
MenuItemDefinition(
id="invoices",
label_key="billing.menu.invoices",
icon="currency-euro",
route="/vendor/{vendor_code}/invoices",
order=30,
),
],
),
MenuSectionDefinition(
id="account",
label_key="billing.menu.account_settings",
icon="credit-card",
order=900,
items=[
MenuItemDefinition(
id="billing",
label_key="billing.menu.billing",
icon="credit-card",
route="/vendor/{vendor_code}/billing",
order=30,
),
],
),
],
},
is_core=False, # Billing can be disabled (e.g., internal platforms) is_core=False, # Billing can be disabled (e.g., internal platforms)
# ========================================================================= # =========================================================================
# Self-Contained Module Configuration # Self-Contained Module Configuration

View File

@@ -89,7 +89,17 @@
"payment_method_updated": "Zahlungsmethode aktualisiert", "payment_method_updated": "Zahlungsmethode aktualisiert",
"subscription_cancelled": "Abonnement gekündigt", "subscription_cancelled": "Abonnement gekündigt",
"error_loading": "Fehler beim Laden der Abrechnungsinformationen", "error_loading": "Fehler beim Laden der Abrechnungsinformationen",
"error_updating": "Fehler beim Aktualisieren des Abonnements" "error_updating": "Fehler beim Aktualisieren des Abonnements",
"failed_to_load_billing_data": "Failed to load billing data",
"failed_to_create_checkout_session": "Failed to create checkout session",
"failed_to_open_payment_portal": "Failed to open payment portal",
"subscription_cancelled_you_have_access_u": "Subscription cancelled. You have access until the end of your billing period.",
"failed_to_cancel_subscription": "Failed to cancel subscription",
"subscription_reactivated": "Subscription reactivated!",
"failed_to_reactivate_subscription": "Failed to reactivate subscription",
"failed_to_purchase_addon": "Failed to purchase add-on",
"addon_cancelled_successfully": "Add-on cancelled successfully",
"failed_to_cancel_addon": "Failed to cancel add-on"
}, },
"limits": { "limits": {
"orders_exceeded": "Monatliches Bestelllimit erreicht. Upgrade für mehr.", "orders_exceeded": "Monatliches Bestelllimit erreicht. Upgrade für mehr.",

View File

@@ -89,7 +89,17 @@
"payment_method_updated": "Payment method updated", "payment_method_updated": "Payment method updated",
"subscription_cancelled": "Subscription cancelled", "subscription_cancelled": "Subscription cancelled",
"error_loading": "Error loading billing information", "error_loading": "Error loading billing information",
"error_updating": "Error updating subscription" "error_updating": "Error updating subscription",
"failed_to_load_billing_data": "Failed to load billing data",
"failed_to_create_checkout_session": "Failed to create checkout session",
"failed_to_open_payment_portal": "Failed to open payment portal",
"subscription_cancelled_you_have_access_u": "Subscription cancelled. You have access until the end of your billing period.",
"failed_to_cancel_subscription": "Failed to cancel subscription",
"subscription_reactivated": "Subscription reactivated!",
"failed_to_reactivate_subscription": "Failed to reactivate subscription",
"failed_to_purchase_addon": "Failed to purchase add-on",
"addon_cancelled_successfully": "Add-on cancelled successfully",
"failed_to_cancel_addon": "Failed to cancel add-on"
}, },
"limits": { "limits": {
"orders_exceeded": "Monthly order limit reached. Upgrade to continue.", "orders_exceeded": "Monthly order limit reached. Upgrade to continue.",

View File

@@ -89,7 +89,17 @@
"payment_method_updated": "Moyen de paiement mis à jour", "payment_method_updated": "Moyen de paiement mis à jour",
"subscription_cancelled": "Abonnement annulé", "subscription_cancelled": "Abonnement annulé",
"error_loading": "Erreur lors du chargement des informations de facturation", "error_loading": "Erreur lors du chargement des informations de facturation",
"error_updating": "Erreur lors de la mise à jour de l'abonnement" "error_updating": "Erreur lors de la mise à jour de l'abonnement",
"failed_to_load_billing_data": "Failed to load billing data",
"failed_to_create_checkout_session": "Failed to create checkout session",
"failed_to_open_payment_portal": "Failed to open payment portal",
"subscription_cancelled_you_have_access_u": "Subscription cancelled. You have access until the end of your billing period.",
"failed_to_cancel_subscription": "Failed to cancel subscription",
"subscription_reactivated": "Subscription reactivated!",
"failed_to_reactivate_subscription": "Failed to reactivate subscription",
"failed_to_purchase_addon": "Failed to purchase add-on",
"addon_cancelled_successfully": "Add-on cancelled successfully",
"failed_to_cancel_addon": "Failed to cancel add-on"
}, },
"limits": { "limits": {
"orders_exceeded": "Limite mensuelle de commandes atteinte. Passez à un niveau supérieur.", "orders_exceeded": "Limite mensuelle de commandes atteinte. Passez à un niveau supérieur.",

View File

@@ -89,7 +89,17 @@
"payment_method_updated": "Zuelungsmethod aktualiséiert", "payment_method_updated": "Zuelungsmethod aktualiséiert",
"subscription_cancelled": "Abonnement gekënnegt", "subscription_cancelled": "Abonnement gekënnegt",
"error_loading": "Feeler beim Lueden vun de Rechnungsinformatiounen", "error_loading": "Feeler beim Lueden vun de Rechnungsinformatiounen",
"error_updating": "Feeler beim Aktualiséieren vum Abonnement" "error_updating": "Feeler beim Aktualiséieren vum Abonnement",
"failed_to_load_billing_data": "Failed to load billing data",
"failed_to_create_checkout_session": "Failed to create checkout session",
"failed_to_open_payment_portal": "Failed to open payment portal",
"subscription_cancelled_you_have_access_u": "Subscription cancelled. You have access until the end of your billing period.",
"failed_to_cancel_subscription": "Failed to cancel subscription",
"subscription_reactivated": "Subscription reactivated!",
"failed_to_reactivate_subscription": "Failed to reactivate subscription",
"failed_to_purchase_addon": "Failed to purchase add-on",
"addon_cancelled_successfully": "Add-on cancelled successfully",
"failed_to_cancel_addon": "Failed to cancel add-on"
}, },
"limits": { "limits": {
"orders_exceeded": "Monatlech Bestellungslimit erreecht. Upgrade fir méi.", "orders_exceeded": "Monatlech Bestellungslimit erreecht. Upgrade fir méi.",

View File

@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db from app.core.database import get_db
from app.modules.billing.services import admin_subscription_service, subscription_service from app.modules.billing.services import admin_subscription_service, subscription_service
from models.database.user import User from app.modules.tenancy.models import User
from app.modules.billing.schemas import ( from app.modules.billing.schemas import (
BillingHistoryListResponse, BillingHistoryListResponse,
BillingHistoryWithVendor, BillingHistoryWithVendor,

View File

@@ -20,7 +20,7 @@ from app.api.deps import get_current_vendor_api, require_module_access
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.modules.billing.services import billing_service, subscription_service from app.modules.billing.services import billing_service, subscription_service
from models.database.user import User from app.modules.tenancy.models import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -15,8 +15,8 @@ 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.core.utils.page_context import get_admin_context
from app.templates_config import templates from app.templates_config import templates
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates from app.templates_config import templates
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -28,7 +28,7 @@ from app.modules.billing.models import (
VendorSubscription, VendorSubscription,
) )
from app.modules.catalog.models import Product from app.modules.catalog.models import Product
from models.database.vendor import Vendor, VendorUser from app.modules.tenancy.models import Vendor, VendorUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -23,7 +23,7 @@ from app.modules.billing.models import (
VendorAddOn, VendorAddOn,
VendorSubscription, VendorSubscription,
) )
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -22,7 +22,7 @@ from app.modules.billing.models import (
SubscriptionStatus, SubscriptionStatus,
VendorSubscription, VendorSubscription,
) )
from models.database.vendor import Vendor, VendorUser from app.modules.tenancy.models import Vendor, VendorUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -27,7 +27,7 @@ from app.modules.billing.models import (
SubscriptionTier, SubscriptionTier,
VendorSubscription, VendorSubscription,
) )
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -308,7 +308,7 @@ class StripeService:
customer_id = subscription.stripe_customer_id customer_id = subscription.stripe_customer_id
else: else:
# Get vendor owner email # Get vendor owner email
from models.database.vendor import VendorUser from app.modules.tenancy.models import VendorUser
owner = ( owner = (
db.query(VendorUser) db.query(VendorUser)

View File

@@ -46,7 +46,7 @@ from app.modules.billing.schemas import (
UsageSummary, UsageSummary,
) )
from app.modules.catalog.models import Product from app.modules.catalog.models import Product
from models.database.vendor import Vendor, VendorUser from app.modules.tenancy.models import Vendor, VendorUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -29,6 +29,9 @@ function vendorBilling() {
// Initialize // Initialize
async init() { async init() {
// Load i18n translations
await I18n.loadModule('billing');
// Guard against multiple initialization // Guard against multiple initialization
if (window._vendorBillingInitialized) return; if (window._vendorBillingInitialized) return;
window._vendorBillingInitialized = true; window._vendorBillingInitialized = true;
@@ -81,7 +84,7 @@ function vendorBilling() {
} catch (error) { } catch (error) {
billingLog.error('Error loading billing data:', error); billingLog.error('Error loading billing data:', error);
Utils.showToast('Failed to load billing data', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_load_billing_data'), 'error');
} finally { } finally {
this.loading = false; this.loading = false;
} }
@@ -101,7 +104,7 @@ function vendorBilling() {
} }
} catch (error) { } catch (error) {
billingLog.error('Error creating checkout:', error); billingLog.error('Error creating checkout:', error);
Utils.showToast('Failed to create checkout session', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_create_checkout_session'), 'error');
} }
}, },
@@ -113,7 +116,7 @@ function vendorBilling() {
} }
} catch (error) { } catch (error) {
billingLog.error('Error opening portal:', error); billingLog.error('Error opening portal:', error);
Utils.showToast('Failed to open payment portal', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_open_payment_portal'), 'error');
} }
}, },
@@ -125,24 +128,24 @@ function vendorBilling() {
}); });
this.showCancelModal = false; this.showCancelModal = false;
Utils.showToast('Subscription cancelled. You have access until the end of your billing period.', 'success'); Utils.showToast(I18n.t('billing.messages.subscription_cancelled_you_have_access_u'), 'success');
await this.loadData(); await this.loadData();
} catch (error) { } catch (error) {
billingLog.error('Error cancelling subscription:', error); billingLog.error('Error cancelling subscription:', error);
Utils.showToast('Failed to cancel subscription', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_cancel_subscription'), 'error');
} }
}, },
async reactivate() { async reactivate() {
try { try {
await apiClient.post('/vendor/billing/reactivate', {}); await apiClient.post('/vendor/billing/reactivate', {});
Utils.showToast('Subscription reactivated!', 'success'); Utils.showToast(I18n.t('billing.messages.subscription_reactivated'), 'success');
await this.loadData(); await this.loadData();
} catch (error) { } catch (error) {
billingLog.error('Error reactivating subscription:', error); billingLog.error('Error reactivating subscription:', error);
Utils.showToast('Failed to reactivate subscription', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_reactivate_subscription'), 'error');
} }
}, },
@@ -159,7 +162,7 @@ function vendorBilling() {
} }
} catch (error) { } catch (error) {
billingLog.error('Error purchasing addon:', error); billingLog.error('Error purchasing addon:', error);
Utils.showToast('Failed to purchase add-on', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_purchase_addon'), 'error');
} finally { } finally {
this.purchasingAddon = null; this.purchasingAddon = null;
} }
@@ -172,11 +175,11 @@ function vendorBilling() {
try { try {
await apiClient.delete(`/vendor/billing/addons/${addon.id}`); await apiClient.delete(`/vendor/billing/addons/${addon.id}`);
Utils.showToast('Add-on cancelled successfully', 'success'); Utils.showToast(I18n.t('billing.messages.addon_cancelled_successfully'), 'success');
await this.loadData(); await this.loadData();
} catch (error) { } catch (error) {
billingLog.error('Error cancelling addon:', error); billingLog.error('Error cancelling addon:', error);
Utils.showToast('Failed to cancel add-on', 'error'); Utils.showToast(I18n.t('billing.messages.failed_to_cancel_addon'), 'error');
} }
}, },

View File

@@ -2,7 +2,7 @@
{# Standalone Pricing Page #} {# Standalone Pricing Page #}
{% extends "public/base.html" %} {% extends "public/base.html" %}
{% block title %}{{ _("platform.pricing.title") }} - Wizamart{% endblock %} {% block title %}{{ _("cms.platform.pricing.title") }} - Wizamart{% endblock %}
{% block content %} {% block content %}
<div x-data="{ annual: false }" class="py-16 lg:py-24"> <div x-data="{ annual: false }" class="py-16 lg:py-24">
@@ -10,15 +10,15 @@
{# Header #} {# Header #}
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4"> <h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.pricing.title") }} {{ _("cms.platform.pricing.title") }}
</h1> </h1>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("platform.pricing.trial_note", trial_days=trial_days) }} {{ _("cms.platform.pricing.trial_note", trial_days=trial_days) }}
</p> </p>
{# Billing Toggle #} {# Billing Toggle #}
<div class="flex items-center justify-center mt-8 space-x-4"> <div class="flex items-center justify-center mt-8 space-x-4">
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': !annual }">{{ _("platform.pricing.monthly") }}</span> <span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': !annual }">{{ _("cms.platform.pricing.monthly") }}</span>
<button @click="annual = !annual" <button @click="annual = !annual"
class="relative w-14 h-7 rounded-full transition-colors" class="relative w-14 h-7 rounded-full transition-colors"
:class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'"> :class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'">
@@ -26,8 +26,8 @@
:class="annual ? 'translate-x-7' : ''"></span> :class="annual ? 'translate-x-7' : ''"></span>
</button> </button>
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }"> <span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }">
{{ _("platform.pricing.annual") }} {{ _("cms.platform.pricing.annual") }}
<span class="text-green-600 text-sm font-medium ml-1">{{ _("platform.pricing.save_months") }}</span> <span class="text-green-600 text-sm font-medium ml-1">{{ _("cms.platform.pricing.save_months") }}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -40,7 +40,7 @@
{% if tier.is_popular %} {% if tier.is_popular %}
<div class="absolute -top-3 left-1/2 -translate-x-1/2"> <div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">{{ _("platform.pricing.recommended") }}</span> <span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">{{ _("cms.platform.pricing.recommended") }}</span>
</div> </div>
{% endif %} {% endif %}
@@ -50,17 +50,17 @@
<template x-if="!annual"> <template x-if="!annual">
<div> <div>
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly }}</span> <span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly }}</span>
<span class="text-gray-500">{{ _("platform.pricing.per_month") }}</span> <span class="text-gray-500">{{ _("cms.platform.pricing.per_month") }}</span>
</div> </div>
</template> </template>
<template x-if="annual"> <template x-if="annual">
<div> <div>
{% if tier.price_annual %} {% if tier.price_annual %}
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}</span> <span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}</span>
<span class="text-gray-500">{{ _("platform.pricing.per_month") }}</span> <span class="text-gray-500">{{ _("cms.platform.pricing.per_month") }}</span>
<div class="text-sm text-gray-500">{{ tier.price_annual }}{{ _("platform.pricing.per_year") }}</div> <div class="text-sm text-gray-500">{{ tier.price_annual }}{{ _("cms.platform.pricing.per_year") }}</div>
{% else %} {% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("platform.pricing.custom") }}</span> <span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("cms.platform.pricing.custom") }}</span>
{% endif %} {% endif %}
</div> </div>
</template> </template>
@@ -71,37 +71,37 @@
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{% if tier.orders_per_month %}{{ _("platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("platform.pricing.unlimited_orders") }}{% endif %} {% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
</li> </li>
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{% if tier.products_limit %}{{ _("platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("platform.pricing.unlimited_products") }}{% endif %} {% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
</li> </li>
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{% if tier.team_members %}{{ _("platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("platform.pricing.unlimited_team") }}{% endif %} {% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
</li> </li>
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{{ _("platform.pricing.letzshop_sync") }} {{ _("cms.platform.pricing.letzshop_sync") }}
</li> </li>
</ul> </ul>
{% if tier.is_enterprise %} {% if tier.is_enterprise %}
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors"> <a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
{{ _("platform.pricing.contact_sales") }} {{ _("cms.platform.pricing.contact_sales") }}
</a> </a>
{% else %} {% else %}
<a :href="'/signup?tier={{ tier.code }}&annual=' + annual" <a :href="'/signup?tier={{ tier.code }}&annual=' + annual"
class="block w-full py-3 font-semibold rounded-xl text-center transition-colors class="block w-full py-3 font-semibold rounded-xl text-center transition-colors
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 text-indigo-700 hover:bg-indigo-200{% endif %}"> {% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 text-indigo-700 hover:bg-indigo-200{% endif %}">
{{ _("platform.pricing.start_trial") }} {{ _("cms.platform.pricing.start_trial") }}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -111,7 +111,7 @@
{# Back to Home #} {# Back to Home #}
<div class="text-center mt-12"> <div class="text-center mt-12">
<a href="/" class="text-indigo-600 dark:text-indigo-400 hover:underline"> <a href="/" class="text-indigo-600 dark:text-indigo-400 hover:underline">
&larr; {{ _("platform.pricing.back_home") }} &larr; {{ _("cms.platform.pricing.back_home") }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
{# Signup Success Page #} {# Signup Success Page #}
{% extends "public/base.html" %} {% extends "public/base.html" %}
{% block title %}{{ _("platform.success.title") }}{% endblock %} {% block title %}{{ _("cms.platform.success.title") }}{% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen py-16 bg-gray-50 dark:bg-gray-900 flex items-center justify-center"> <div class="min-h-screen py-16 bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
@@ -17,23 +17,23 @@
{# Welcome Message #} {# Welcome Message #}
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.success.title") }} {{ _("cms.platform.success.title") }}
</h1> </h1>
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8"> <p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
{{ _("platform.success.subtitle", trial_days=trial_days) }} {{ _("cms.platform.success.subtitle", trial_days=trial_days) }}
</p> </p>
{# Next Steps #} {# Next Steps #}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-left mb-8"> <div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-left mb-8">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("platform.success.what_next") }}</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("cms.platform.success.what_next") }}</h2>
<ul class="space-y-4"> <ul class="space-y-4">
<li class="flex items-start"> <li class="flex items-start">
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">1</span> <span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">1</span>
</div> </div>
<span class="ml-3 text-gray-700 dark:text-gray-300"> <span class="ml-3 text-gray-700 dark:text-gray-300">
<strong>{{ _("platform.success.step_connect") }}</strong> {{ _("platform.success.step_connect_desc") }} <strong>{{ _("cms.platform.success.step_connect") }}</strong> {{ _("cms.platform.success.step_connect_desc") }}
</span> </span>
</li> </li>
<li class="flex items-start"> <li class="flex items-start">
@@ -41,7 +41,7 @@
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">2</span> <span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">2</span>
</div> </div>
<span class="ml-3 text-gray-700 dark:text-gray-300"> <span class="ml-3 text-gray-700 dark:text-gray-300">
<strong>{{ _("platform.success.step_invoicing") }}</strong> {{ _("platform.success.step_invoicing_desc") }} <strong>{{ _("cms.platform.success.step_invoicing") }}</strong> {{ _("cms.platform.success.step_invoicing_desc") }}
</span> </span>
</li> </li>
<li class="flex items-start"> <li class="flex items-start">
@@ -49,7 +49,7 @@
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">3</span> <span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">3</span>
</div> </div>
<span class="ml-3 text-gray-700 dark:text-gray-300"> <span class="ml-3 text-gray-700 dark:text-gray-300">
<strong>{{ _("platform.success.step_products") }}</strong> {{ _("platform.success.step_products_desc") }} <strong>{{ _("cms.platform.success.step_products") }}</strong> {{ _("cms.platform.success.step_products_desc") }}
</span> </span>
</li> </li>
</ul> </ul>
@@ -59,7 +59,7 @@
{% if vendor_code %} {% if vendor_code %}
<a href="/vendor/{{ vendor_code }}/dashboard" <a href="/vendor/{{ vendor_code }}/dashboard"
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all hover:scale-105"> class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all hover:scale-105">
{{ _("platform.success.go_to_dashboard") }} {{ _("cms.platform.success.go_to_dashboard") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg> </svg>
@@ -67,14 +67,14 @@
{% else %} {% else %}
<a href="/admin/login" <a href="/admin/login"
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all"> class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all">
{{ _("platform.success.login_dashboard") }} {{ _("cms.platform.success.login_dashboard") }}
</a> </a>
{% endif %} {% endif %}
{# Support Link #} {# Support Link #}
<p class="mt-8 text-gray-500 dark:text-gray-400"> <p class="mt-8 text-gray-500 dark:text-gray-400">
{{ _("platform.success.need_help") }} {{ _("cms.platform.success.need_help") }}
<a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("platform.success.contact_support") }}</a> <a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("cms.platform.success.contact_support") }}</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -24,7 +24,7 @@ from app.modules.cart.schemas import (
UpdateCartItemRequest, UpdateCartItemRequest,
) )
from middleware.vendor_context import require_vendor_context from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -1,7 +1,8 @@
# app/modules/catalog/definition.py # app/modules/catalog/definition.py
"""Catalog module definition.""" """Catalog module definition."""
from app.modules.base import ModuleDefinition from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from app.modules.enums import FrontendType
module = ModuleDefinition( module = ModuleDefinition(
code="catalog", code="catalog",
@@ -10,4 +11,25 @@ module = ModuleDefinition(
version="1.0.0", version="1.0.0",
is_self_contained=True, is_self_contained=True,
requires=["inventory"], requires=["inventory"],
# New module-driven menu definitions
menus={
FrontendType.VENDOR: [
MenuSectionDefinition(
id="products",
label_key="catalog.menu.products_inventory",
icon="package",
order=10,
items=[
MenuItemDefinition(
id="products",
label_key="catalog.menu.all_products",
icon="shopping-bag",
route="/vendor/{vendor_code}/products",
order=10,
is_mandatory=True,
),
],
),
],
},
) )

View File

@@ -1,49 +1,64 @@
{ {
"title": "Produktkatalog",
"description": "Produktkatalogverwaltung für Händler",
"products": { "products": {
"title": "Produkte", "title": "Produkte",
"subtitle": "Verwalten Sie Ihren Produktkatalog", "product": "Produkt",
"create": "Produkt erstellen", "add_product": "Produkt hinzufügen",
"edit": "Produkt bearbeiten", "edit_product": "Produkt bearbeiten",
"delete": "Produkt löschen", "delete_product": "Produkt löschen",
"empty": "Keine Produkte gefunden", "product_name": "Produktname",
"empty_search": "Keine Produkte entsprechen Ihrer Suche" "product_code": "Produktcode",
}, "sku": "SKU",
"product": {
"name": "Produktname",
"description": "Beschreibung",
"sku": "Artikelnummer",
"price": "Preis", "price": "Preis",
"stock": "Bestand", "sale_price": "Verkaufspreis",
"status": "Status", "cost": "Kosten",
"active": "Aktiv", "stock": "Lagerbestand",
"inactive": "Inaktiv" "in_stock": "Auf Lager",
}, "out_of_stock": "Nicht auf Lager",
"media": { "low_stock": "Geringer Bestand",
"title": "Produktmedien", "availability": "Verfügbarkeit",
"upload": "Bild hochladen", "available": "Verfügbar",
"delete": "Bild löschen", "unavailable": "Nicht verfügbar",
"primary": "Als Hauptbild festlegen", "brand": "Marke",
"error": "Medien-Upload fehlgeschlagen" "category": "Kategorie",
}, "categories": "Kategorien",
"validation": { "image": "Bild",
"name_required": "Produktname ist erforderlich", "images": "Bilder",
"price_required": "Preis ist erforderlich", "main_image": "Hauptbild",
"invalid_sku": "Ungültiges Artikelnummernformat", "gallery": "Galerie",
"duplicate_sku": "Artikelnummer existiert bereits" "weight": "Gewicht",
"dimensions": "Abmessungen",
"color": "Farbe",
"size": "Größe",
"material": "Material",
"condition": "Zustand",
"new": "Neu",
"used": "Gebraucht",
"refurbished": "Generalüberholt",
"no_products": "Keine Produkte gefunden",
"search_products": "Produkte suchen...",
"filter_by_category": "Nach Kategorie filtern",
"filter_by_status": "Nach Status filtern",
"sort_by": "Sortieren nach",
"sort_newest": "Neueste",
"sort_oldest": "Älteste",
"sort_price_low": "Preis: Niedrig bis Hoch",
"sort_price_high": "Preis: Hoch bis Niedrig",
"sort_name_az": "Name: A-Z",
"sort_name_za": "Name: Z-A"
}, },
"messages": { "messages": {
"created": "Produkt erfolgreich erstellt", "product_deleted_successfully": "Product deleted successfully",
"updated": "Produkt erfolgreich aktualisiert", "please_fill_in_all_required_fields": "Please fill in all required fields",
"deleted": "Produkt erfolgreich gelöscht", "product_updated_successfully": "Product updated successfully",
"not_found": "Produkt nicht gefunden", "failed_to_load_media_library": "Failed to load media library",
"cannot_delete": "Produkt kann nicht gelöscht werden", "no_vendor_associated_with_this_product": "No vendor associated with this product",
"error_loading": "Fehler beim Laden der Produkte" "please_select_an_image_file": "Please select an image file",
}, "image_must_be_less_than_10mb": "Image must be less than 10MB",
"filters": { "image_uploaded_successfully": "Image uploaded successfully",
"all_products": "Alle Produkte", "product_removed_from_vendor_catalog": "Product removed from vendor catalog.",
"active_only": "Nur aktive", "please_select_a_vendor": "Please select a vendor",
"search_placeholder": "Produkte suchen..." "please_enter_a_product_title_english": "Please enter a product title (English)",
"product_created_successfully": "Product created successfully",
"please_select_a_vendor_first": "Please select a vendor first"
} }
} }

View File

@@ -1,49 +1,64 @@
{ {
"title": "Catalogue produits",
"description": "Gestion du catalogue produits pour les vendeurs",
"products": { "products": {
"title": "Produits", "title": "Produits",
"subtitle": "Gérez votre catalogue de produits", "product": "Produit",
"create": "Créer un produit", "add_product": "Ajouter un produit",
"edit": "Modifier le produit", "edit_product": "Modifier le produit",
"delete": "Supprimer le produit", "delete_product": "Supprimer le produit",
"empty": "Aucun produit trouvé", "product_name": "Nom du produit",
"empty_search": "Aucun produit ne correspond à votre recherche" "product_code": "Code produit",
}, "sku": "SKU",
"product": {
"name": "Nom du produit",
"description": "Description",
"sku": "Référence",
"price": "Prix", "price": "Prix",
"sale_price": "Prix de vente",
"cost": "Coût",
"stock": "Stock", "stock": "Stock",
"status": "Statut", "in_stock": "En stock",
"active": "Actif", "out_of_stock": "Rupture de stock",
"inactive": "Inactif" "low_stock": "Stock faible",
}, "availability": "Disponibilité",
"media": { "available": "Disponible",
"title": "Médias du produit", "unavailable": "Indisponible",
"upload": "Télécharger une image", "brand": "Marque",
"delete": "Supprimer l'image", "category": "Catégorie",
"primary": "Définir comme image principale", "categories": "Catégories",
"error": "Échec du téléchargement" "image": "Image",
}, "images": "Images",
"validation": { "main_image": "Image principale",
"name_required": "Le nom du produit est requis", "gallery": "Galerie",
"price_required": "Le prix est requis", "weight": "Poids",
"invalid_sku": "Format de référence invalide", "dimensions": "Dimensions",
"duplicate_sku": "La référence existe déjà" "color": "Couleur",
"size": "Taille",
"material": "Matériau",
"condition": "État",
"new": "Neuf",
"used": "Occasion",
"refurbished": "Reconditionné",
"no_products": "Aucun produit trouvé",
"search_products": "Rechercher des produits...",
"filter_by_category": "Filtrer par catégorie",
"filter_by_status": "Filtrer par statut",
"sort_by": "Trier par",
"sort_newest": "Plus récent",
"sort_oldest": "Plus ancien",
"sort_price_low": "Prix : croissant",
"sort_price_high": "Prix : décroissant",
"sort_name_az": "Nom : A-Z",
"sort_name_za": "Nom : Z-A"
}, },
"messages": { "messages": {
"created": "Produit créé avec succès", "product_deleted_successfully": "Product deleted successfully",
"updated": "Produit mis à jour avec succès", "please_fill_in_all_required_fields": "Please fill in all required fields",
"deleted": "Produit supprimé avec succès", "product_updated_successfully": "Product updated successfully",
"not_found": "Produit non trouvé", "failed_to_load_media_library": "Failed to load media library",
"cannot_delete": "Impossible de supprimer le produit", "no_vendor_associated_with_this_product": "No vendor associated with this product",
"error_loading": "Erreur lors du chargement des produits" "please_select_an_image_file": "Please select an image file",
}, "image_must_be_less_than_10mb": "Image must be less than 10MB",
"filters": { "image_uploaded_successfully": "Image uploaded successfully",
"all_products": "Tous les produits", "product_removed_from_vendor_catalog": "Product removed from vendor catalog.",
"active_only": "Actifs uniquement", "please_select_a_vendor": "Please select a vendor",
"search_placeholder": "Rechercher des produits..." "please_enter_a_product_title_english": "Please enter a product title (English)",
"product_created_successfully": "Product created successfully",
"please_select_a_vendor_first": "Please select a vendor first"
} }
} }

View File

@@ -1,49 +1,64 @@
{ {
"title": "Produktkatalog",
"description": "Produktkatalogverwaltung fir Händler",
"products": { "products": {
"title": "Produkter", "title": "Produkter",
"subtitle": "Verwalte Äre Produktkatalog", "product": "Produkt",
"create": "Produkt erstellen", "add_product": "Produkt derbäisetzen",
"edit": "Produkt beaarbechten", "edit_product": "Produkt änneren",
"delete": "Produkt läschen", "delete_product": "Produkt läschen",
"empty": "Keng Produkter fonnt", "product_name": "Produktnumm",
"empty_search": "Keng Produkter entspriechen Ärer Sich" "product_code": "Produktcode",
}, "sku": "SKU",
"product": {
"name": "Produktnumm",
"description": "Beschreiwung",
"sku": "Artikelnummer",
"price": "Präis", "price": "Präis",
"stock": "Bestand", "sale_price": "Verkafspräis",
"status": "Status", "cost": "Käschten",
"active": "Aktiv", "stock": "Lager",
"inactive": "Inaktiv" "in_stock": "Op Lager",
}, "out_of_stock": "Net op Lager",
"media": { "low_stock": "Niddregen Stock",
"title": "Produktmedien", "availability": "Disponibilitéit",
"upload": "Bild eroplueden", "available": "Disponibel",
"delete": "Bild läschen", "unavailable": "Net disponibel",
"primary": "Als Haaptbild setzen", "brand": "Mark",
"error": "Medien-Upload feelgeschloen" "category": "Kategorie",
}, "categories": "Kategorien",
"validation": { "image": "Bild",
"name_required": "Produktnumm ass erfuerderlech", "images": "Biller",
"price_required": "Präis ass erfuerderlech", "main_image": "Haaptbild",
"invalid_sku": "Ongëlteg Artikelnummerformat", "gallery": "Galerie",
"duplicate_sku": "Artikelnummer existéiert schonn" "weight": "Gewiicht",
"dimensions": "Dimensiounen",
"color": "Faarf",
"size": "Gréisst",
"material": "Material",
"condition": "Zoustand",
"new": "Nei",
"used": "Gebraucht",
"refurbished": "Iwwerholl",
"no_products": "Keng Produkter fonnt",
"search_products": "Produkter sichen...",
"filter_by_category": "No Kategorie filteren",
"filter_by_status": "No Status filteren",
"sort_by": "Sortéieren no",
"sort_newest": "Neisten",
"sort_oldest": "Eelsten",
"sort_price_low": "Präis: Niddreg op Héich",
"sort_price_high": "Präis: Héich op Niddreg",
"sort_name_az": "Numm: A-Z",
"sort_name_za": "Numm: Z-A"
}, },
"messages": { "messages": {
"created": "Produkt erfollegräich erstallt", "product_deleted_successfully": "Product deleted successfully",
"updated": "Produkt erfollegräich aktualiséiert", "please_fill_in_all_required_fields": "Please fill in all required fields",
"deleted": "Produkt erfollegräich geläscht", "product_updated_successfully": "Product updated successfully",
"not_found": "Produkt net fonnt", "failed_to_load_media_library": "Failed to load media library",
"cannot_delete": "Produkt kann net geläscht ginn", "no_vendor_associated_with_this_product": "No vendor associated with this product",
"error_loading": "Feeler beim Lueden vun de Produkter" "please_select_an_image_file": "Please select an image file",
}, "image_must_be_less_than_10mb": "Image must be less than 10MB",
"filters": { "image_uploaded_successfully": "Image uploaded successfully",
"all_products": "All Produkter", "product_removed_from_vendor_catalog": "Product removed from vendor catalog.",
"active_only": "Nëmmen aktiv", "please_select_a_vendor": "Please select a vendor",
"search_placeholder": "Produkter sichen..." "please_enter_a_product_title_english": "Please enter a product title (English)",
"product_created_successfully": "Product created successfully",
"please_select_a_vendor_first": "Please select a vendor first"
} }
} }

View File

@@ -22,7 +22,7 @@ from app.modules.catalog.schemas import (
ProductResponse, ProductResponse,
) )
from middleware.vendor_context import require_vendor_context from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -15,8 +15,8 @@ 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.core.utils.page_context import get_admin_context
from app.templates_config import templates from app.templates_config import templates
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates from app.templates_config import templates
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session, joinedload
from app.modules.catalog.exceptions import ProductNotFoundException from app.modules.catalog.exceptions import ProductNotFoundException
from app.modules.catalog.models import Product from app.modules.catalog.models import Product
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -66,6 +66,9 @@ function adminVendorProductCreate() {
}, },
async init() { async init() {
// Load i18n translations
await I18n.loadModule('catalog');
adminVendorProductCreateLog.info('Vendor Product Create init() called'); adminVendorProductCreateLog.info('Vendor Product Create init() called');
// Guard against multiple initialization // Guard against multiple initialization
@@ -166,12 +169,12 @@ function adminVendorProductCreate() {
*/ */
async createProduct() { async createProduct() {
if (!this.form.vendor_id) { if (!this.form.vendor_id) {
Utils.showToast('Please select a vendor', 'error'); Utils.showToast(I18n.t('catalog.messages.please_select_a_vendor'), 'error');
return; return;
} }
if (!this.form.translations.en.title?.trim()) { if (!this.form.translations.en.title?.trim()) {
Utils.showToast('Please enter a product title (English)', 'error'); Utils.showToast(I18n.t('catalog.messages.please_enter_a_product_title_english'), 'error');
return; return;
} }
@@ -224,7 +227,7 @@ function adminVendorProductCreate() {
adminVendorProductCreateLog.info('Product created:', response.id); adminVendorProductCreateLog.info('Product created:', response.id);
Utils.showToast('Product created successfully', 'success'); Utils.showToast(I18n.t('catalog.messages.product_created_successfully'), 'success');
// Redirect to the new product's detail page // Redirect to the new product's detail page
setTimeout(() => { setTimeout(() => {
@@ -232,7 +235,7 @@ function adminVendorProductCreate() {
}, 1000); }, 1000);
} catch (error) { } catch (error) {
adminVendorProductCreateLog.error('Failed to create product:', error); adminVendorProductCreateLog.error('Failed to create product:', error);
Utils.showToast(error.message || 'Failed to create product', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_create_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -274,7 +277,7 @@ function adminVendorProductCreate() {
this.mediaPickerState.total = response.total || 0; this.mediaPickerState.total = response.total || 0;
} catch (error) { } catch (error) {
adminVendorProductCreateLog.error('Failed to load media library:', error); adminVendorProductCreateLog.error('Failed to load media library:', error);
Utils.showToast('Failed to load media library', 'error'); Utils.showToast(I18n.t('catalog.messages.failed_to_load_media_library'), 'error');
} finally { } finally {
this.mediaPickerState.loading = false; this.mediaPickerState.loading = false;
} }
@@ -326,17 +329,17 @@ function adminVendorProductCreate() {
const vendorId = this.form?.vendor_id; const vendorId = this.form?.vendor_id;
if (!vendorId) { if (!vendorId) {
Utils.showToast('Please select a vendor first', 'error'); Utils.showToast(I18n.t('catalog.messages.please_select_a_vendor_first'), 'error');
return; return;
} }
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
Utils.showToast('Please select an image file', 'error'); Utils.showToast(I18n.t('catalog.messages.please_select_an_image_file'), 'error');
return; return;
} }
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
Utils.showToast('Image must be less than 10MB', 'error'); Utils.showToast(I18n.t('catalog.messages.image_must_be_less_than_10mb'), 'error');
return; return;
} }
@@ -355,11 +358,11 @@ function adminVendorProductCreate() {
this.mediaPickerState.media.unshift(response.media); this.mediaPickerState.media.unshift(response.media);
this.mediaPickerState.total++; this.mediaPickerState.total++;
this.toggleMediaSelection(response.media); this.toggleMediaSelection(response.media);
Utils.showToast('Image uploaded successfully', 'success'); Utils.showToast(I18n.t('catalog.messages.image_uploaded_successfully'), 'success');
} }
} catch (error) { } catch (error) {
adminVendorProductCreateLog.error('Failed to upload image:', error); adminVendorProductCreateLog.error('Failed to upload image:', error);
Utils.showToast(error.message || 'Failed to upload image', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_upload_image'), 'error');
} finally { } finally {
this.mediaPickerState.uploading = false; this.mediaPickerState.uploading = false;
event.target.value = ''; event.target.value = '';

View File

@@ -76,6 +76,9 @@ function adminVendorProductEdit() {
}, },
async init() { async init() {
// Load i18n translations
await I18n.loadModule('catalog');
adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId); adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId);
// Guard against multiple initialization // Guard against multiple initialization
@@ -209,7 +212,7 @@ function adminVendorProductEdit() {
*/ */
async saveProduct() { async saveProduct() {
if (!this.isFormValid()) { if (!this.isFormValid()) {
Utils.showToast('Please fill in all required fields', 'error'); Utils.showToast(I18n.t('catalog.messages.please_fill_in_all_required_fields'), 'error');
return; return;
} }
@@ -266,7 +269,7 @@ function adminVendorProductEdit() {
adminVendorProductEditLog.info('Product saved:', this.productId); adminVendorProductEditLog.info('Product saved:', this.productId);
Utils.showToast('Product updated successfully', 'success'); Utils.showToast(I18n.t('catalog.messages.product_updated_successfully'), 'success');
// Redirect to detail page // Redirect to detail page
setTimeout(() => { setTimeout(() => {
@@ -274,7 +277,7 @@ function adminVendorProductEdit() {
}, 1000); }, 1000);
} catch (error) { } catch (error) {
adminVendorProductEditLog.error('Failed to save product:', error); adminVendorProductEditLog.error('Failed to save product:', error);
Utils.showToast(error.message || 'Failed to save product', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_save_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -316,7 +319,7 @@ function adminVendorProductEdit() {
this.mediaPickerState.total = response.total || 0; this.mediaPickerState.total = response.total || 0;
} catch (error) { } catch (error) {
adminVendorProductEditLog.error('Failed to load media library:', error); adminVendorProductEditLog.error('Failed to load media library:', error);
Utils.showToast('Failed to load media library', 'error'); Utils.showToast(I18n.t('catalog.messages.failed_to_load_media_library'), 'error');
} finally { } finally {
this.mediaPickerState.loading = false; this.mediaPickerState.loading = false;
} }
@@ -368,17 +371,17 @@ function adminVendorProductEdit() {
const vendorId = this.product?.vendor_id; const vendorId = this.product?.vendor_id;
if (!vendorId) { if (!vendorId) {
Utils.showToast('No vendor associated with this product', 'error'); Utils.showToast(I18n.t('catalog.messages.no_vendor_associated_with_this_product'), 'error');
return; return;
} }
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
Utils.showToast('Please select an image file', 'error'); Utils.showToast(I18n.t('catalog.messages.please_select_an_image_file'), 'error');
return; return;
} }
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
Utils.showToast('Image must be less than 10MB', 'error'); Utils.showToast(I18n.t('catalog.messages.image_must_be_less_than_10mb'), 'error');
return; return;
} }
@@ -397,11 +400,11 @@ function adminVendorProductEdit() {
this.mediaPickerState.media.unshift(response.media); this.mediaPickerState.media.unshift(response.media);
this.mediaPickerState.total++; this.mediaPickerState.total++;
this.toggleMediaSelection(response.media); this.toggleMediaSelection(response.media);
Utils.showToast('Image uploaded successfully', 'success'); Utils.showToast(I18n.t('catalog.messages.image_uploaded_successfully'), 'success');
} }
} catch (error) { } catch (error) {
adminVendorProductEditLog.error('Failed to upload image:', error); adminVendorProductEditLog.error('Failed to upload image:', error);
Utils.showToast(error.message || 'Failed to upload image', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_upload_image'), 'error');
} finally { } finally {
this.mediaPickerState.uploading = false; this.mediaPickerState.uploading = false;
event.target.value = ''; event.target.value = '';

View File

@@ -116,6 +116,9 @@ function adminVendorProducts() {
}, },
async init() { async init() {
// Load i18n translations
await I18n.loadModule('catalog');
adminVendorProductsLog.info('Vendor Products init() called'); adminVendorProductsLog.info('Vendor Products init() called');
// Guard against multiple initialization // Guard against multiple initialization
@@ -385,7 +388,7 @@ function adminVendorProducts() {
this.productToRemove = null; this.productToRemove = null;
// Show success notification // Show success notification
Utils.showToast('Product removed from vendor catalog.', 'success'); Utils.showToast(I18n.t('catalog.messages.product_removed_from_vendor_catalog'), 'success');
// Refresh the list // Refresh the list
await this.refresh(); await this.refresh();

View File

@@ -112,6 +112,9 @@ function vendorProducts() {
}, },
async init() { async init() {
// Load i18n translations
await I18n.loadModule('catalog');
vendorProductsLog.info('Products init() called'); vendorProductsLog.info('Products init() called');
// Guard against multiple initialization // Guard against multiple initialization
@@ -230,13 +233,13 @@ function vendorProducts() {
await apiClient.put(`/vendor/products/${product.id}/toggle-active`); await apiClient.put(`/vendor/products/${product.id}/toggle-active`);
product.is_active = !product.is_active; product.is_active = !product.is_active;
Utils.showToast( Utils.showToast(
product.is_active ? 'Product activated' : 'Product deactivated', product.is_active ? I18n.t('catalog.messages.product_activated') : I18n.t('catalog.messages.product_deactivated'),
'success' 'success'
); );
vendorProductsLog.info('Toggled product active:', product.id, product.is_active); vendorProductsLog.info('Toggled product active:', product.id, product.is_active);
} catch (error) { } catch (error) {
vendorProductsLog.error('Failed to toggle active:', error); vendorProductsLog.error('Failed to toggle active:', error);
Utils.showToast(error.message || 'Failed to update product', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -251,13 +254,13 @@ function vendorProducts() {
await apiClient.put(`/vendor/products/${product.id}/toggle-featured`); await apiClient.put(`/vendor/products/${product.id}/toggle-featured`);
product.is_featured = !product.is_featured; product.is_featured = !product.is_featured;
Utils.showToast( Utils.showToast(
product.is_featured ? 'Product marked as featured' : 'Product unmarked as featured', product.is_featured ? I18n.t('catalog.messages.product_marked_as_featured') : I18n.t('catalog.messages.product_unmarked_as_featured'),
'success' 'success'
); );
vendorProductsLog.info('Toggled product featured:', product.id, product.is_featured); vendorProductsLog.info('Toggled product featured:', product.id, product.is_featured);
} catch (error) { } catch (error) {
vendorProductsLog.error('Failed to toggle featured:', error); vendorProductsLog.error('Failed to toggle featured:', error);
Utils.showToast(error.message || 'Failed to update product', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -288,7 +291,7 @@ function vendorProducts() {
this.saving = true; this.saving = true;
try { try {
await apiClient.delete(`/vendor/products/${this.selectedProduct.id}`); await apiClient.delete(`/vendor/products/${this.selectedProduct.id}`);
Utils.showToast('Product deleted successfully', 'success'); Utils.showToast(I18n.t('catalog.messages.product_deleted_successfully'), 'success');
vendorProductsLog.info('Deleted product:', this.selectedProduct.id); vendorProductsLog.info('Deleted product:', this.selectedProduct.id);
this.showDeleteModal = false; this.showDeleteModal = false;
@@ -296,7 +299,7 @@ function vendorProducts() {
await this.loadProducts(); await this.loadProducts();
} catch (error) { } catch (error) {
vendorProductsLog.error('Failed to delete product:', error); vendorProductsLog.error('Failed to delete product:', error);
Utils.showToast(error.message || 'Failed to delete product', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_delete_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -417,12 +420,12 @@ function vendorProducts() {
successCount++; successCount++;
} }
} }
Utils.showToast(`${successCount} product(s) activated`, 'success'); Utils.showToast(I18n.t('catalog.messages.products_activated', { count: successCount }), 'success');
this.clearSelection(); this.clearSelection();
await this.loadProducts(); await this.loadProducts();
} catch (error) { } catch (error) {
vendorProductsLog.error('Bulk activate failed:', error); vendorProductsLog.error('Bulk activate failed:', error);
Utils.showToast(error.message || 'Failed to activate products', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_activate_products'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -445,12 +448,12 @@ function vendorProducts() {
successCount++; successCount++;
} }
} }
Utils.showToast(`${successCount} product(s) deactivated`, 'success'); Utils.showToast(I18n.t('catalog.messages.products_deactivated', { count: successCount }), 'success');
this.clearSelection(); this.clearSelection();
await this.loadProducts(); await this.loadProducts();
} catch (error) { } catch (error) {
vendorProductsLog.error('Bulk deactivate failed:', error); vendorProductsLog.error('Bulk deactivate failed:', error);
Utils.showToast(error.message || 'Failed to deactivate products', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_deactivate_products'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -473,12 +476,12 @@ function vendorProducts() {
successCount++; successCount++;
} }
} }
Utils.showToast(`${successCount} product(s) marked as featured`, 'success'); Utils.showToast(I18n.t('catalog.messages.products_marked_as_featured', { count: successCount }), 'success');
this.clearSelection(); this.clearSelection();
await this.loadProducts(); await this.loadProducts();
} catch (error) { } catch (error) {
vendorProductsLog.error('Bulk set featured failed:', error); vendorProductsLog.error('Bulk set featured failed:', error);
Utils.showToast(error.message || 'Failed to update products', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -501,12 +504,12 @@ function vendorProducts() {
successCount++; successCount++;
} }
} }
Utils.showToast(`${successCount} product(s) unmarked as featured`, 'success'); Utils.showToast(I18n.t('catalog.messages.products_unmarked_as_featured', { count: successCount }), 'success');
this.clearSelection(); this.clearSelection();
await this.loadProducts(); await this.loadProducts();
} catch (error) { } catch (error) {
vendorProductsLog.error('Bulk remove featured failed:', error); vendorProductsLog.error('Bulk remove featured failed:', error);
Utils.showToast(error.message || 'Failed to update products', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_update_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }
@@ -533,13 +536,13 @@ function vendorProducts() {
await apiClient.delete(`/vendor/products/${productId}`); await apiClient.delete(`/vendor/products/${productId}`);
successCount++; successCount++;
} }
Utils.showToast(`${successCount} product(s) deleted`, 'success'); Utils.showToast(I18n.t('catalog.messages.products_deleted', { count: successCount }), 'success');
this.showBulkDeleteModal = false; this.showBulkDeleteModal = false;
this.clearSelection(); this.clearSelection();
await this.loadProducts(); await this.loadProducts();
} catch (error) { } catch (error) {
vendorProductsLog.error('Bulk delete failed:', error); vendorProductsLog.error('Bulk delete failed:', error);
Utils.showToast(error.message || 'Failed to delete products', 'error'); Utils.showToast(error.message || I18n.t('catalog.messages.failed_to_delete_product'), 'error');
} finally { } finally {
this.saving = false; this.saving = false;
} }

View File

@@ -1,42 +1,20 @@
{ {
"title": "Kasse", "storefront": {
"description": "Bestellabwicklung und Zahlungsabwicklung", "welcome": "Willkommen in unserem Shop",
"session": { "browse_products": "Produkte durchstöbern",
"title": "Checkout-Sitzung", "add_to_cart": "In den Warenkorb",
"expired": "Sitzung abgelaufen", "buy_now": "Jetzt kaufen",
"invalid": "Ungültige Sitzung" "view_cart": "Warenkorb ansehen",
}, "checkout": "Zur Kasse",
"shipping": { "continue_shopping": "Weiter einkaufen",
"title": "Lieferadresse", "start_shopping": "Einkaufen starten",
"select_address": "Adresse auswählen", "empty_cart": "Ihr Warenkorb ist leer",
"add_new": "Neue Adresse hinzufügen", "cart_total": "Warenkorbsumme",
"method": "Versandart", "proceed_checkout": "Zur Kasse gehen",
"select_method": "Versandart auswählen", "payment": "Zahlung",
"not_available": "Für diese Adresse nicht verfügbar" "place_order": "Bestellung aufgeben",
},
"payment": {
"title": "Zahlung",
"method": "Zahlungsmethode",
"required": "Zahlung erforderlich",
"failed": "Zahlung fehlgeschlagen"
},
"order": {
"summary": "Bestellübersicht",
"subtotal": "Zwischensumme",
"shipping": "Versand",
"tax": "MwSt.",
"total": "Gesamtsumme",
"place_order": "Bestellung aufgeben"
},
"validation": {
"empty_cart": "Warenkorb ist leer",
"invalid_address": "Ungültige Lieferadresse",
"insufficient_inventory": "Unzureichender Bestand"
},
"messages": {
"order_placed": "Bestellung erfolgreich aufgegeben", "order_placed": "Bestellung erfolgreich aufgegeben",
"checkout_failed": "Checkout fehlgeschlagen", "thank_you": "Vielen Dank für Ihre Bestellung",
"session_expired": "Ihre Sitzung ist abgelaufen", "order_confirmation": "Bestellbestätigung"
"inventory_error": "Einige Artikel sind nicht mehr verfügbar"
} }
} }

View File

@@ -1,42 +1,20 @@
{ {
"title": "Checkout", "storefront": {
"description": "Order checkout and payment processing", "welcome": "Welcome to our store",
"session": { "browse_products": "Browse Products",
"title": "Checkout Session", "add_to_cart": "Add to Cart",
"expired": "Session expired", "buy_now": "Buy Now",
"invalid": "Invalid session" "view_cart": "View Cart",
}, "checkout": "Checkout",
"shipping": { "continue_shopping": "Continue Shopping",
"title": "Shipping Address", "start_shopping": "Start Shopping",
"select_address": "Select Address", "empty_cart": "Your cart is empty",
"add_new": "Add New Address", "cart_total": "Cart Total",
"method": "Shipping Method", "proceed_checkout": "Proceed to Checkout",
"select_method": "Select Shipping Method", "payment": "Payment",
"not_available": "Not available for this address" "place_order": "Place Order",
}, "order_placed": "Order Placed Successfully",
"payment": { "thank_you": "Thank you for your order",
"title": "Payment", "order_confirmation": "Order Confirmation"
"method": "Payment Method",
"required": "Payment required",
"failed": "Payment failed"
},
"order": {
"summary": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"tax": "Tax",
"total": "Total",
"place_order": "Place Order"
},
"validation": {
"empty_cart": "Cart is empty",
"invalid_address": "Invalid shipping address",
"insufficient_inventory": "Insufficient inventory"
},
"messages": {
"order_placed": "Order placed successfully",
"checkout_failed": "Checkout failed",
"session_expired": "Your session has expired",
"inventory_error": "Some items are no longer available"
} }
} }

View File

@@ -1,42 +1,20 @@
{ {
"title": "Caisse", "storefront": {
"description": "Traitement des commandes et des paiements", "welcome": "Bienvenue dans notre boutique",
"session": { "browse_products": "Parcourir les produits",
"title": "Session de paiement", "add_to_cart": "Ajouter au panier",
"expired": "Session expirée", "buy_now": "Acheter maintenant",
"invalid": "Session invalide" "view_cart": "Voir le panier",
}, "checkout": "Paiement",
"shipping": { "continue_shopping": "Continuer vos achats",
"title": "Adresse de livraison", "start_shopping": "Commencer vos achats",
"select_address": "Sélectionner une adresse", "empty_cart": "Votre panier est vide",
"add_new": "Ajouter une nouvelle adresse", "cart_total": "Total du panier",
"method": "Mode de livraison", "proceed_checkout": "Passer à la caisse",
"select_method": "Sélectionner un mode de livraison", "payment": "Paiement",
"not_available": "Non disponible pour cette adresse" "place_order": "Passer la commande",
},
"payment": {
"title": "Paiement",
"method": "Mode de paiement",
"required": "Paiement requis",
"failed": "Paiement échoué"
},
"order": {
"summary": "Récapitulatif de commande",
"subtotal": "Sous-total",
"shipping": "Livraison",
"tax": "TVA",
"total": "Total",
"place_order": "Passer la commande"
},
"validation": {
"empty_cart": "Le panier est vide",
"invalid_address": "Adresse de livraison invalide",
"insufficient_inventory": "Stock insuffisant"
},
"messages": {
"order_placed": "Commande passée avec succès", "order_placed": "Commande passée avec succès",
"checkout_failed": "Échec du paiement", "thank_you": "Merci pour votre commande",
"session_expired": "Votre session a expiré", "order_confirmation": "Confirmation de commande"
"inventory_error": "Certains articles ne sont plus disponibles"
} }
} }

View File

@@ -1,42 +1,20 @@
{ {
"title": "Keess", "storefront": {
"description": "Bestellungsofwécklung a Bezuelung", "welcome": "Wëllkomm an eisem Buttek",
"session": { "browse_products": "Produkter duerchsichen",
"title": "Checkout-Sëtzung", "add_to_cart": "An de Kuerf",
"expired": "Sëtzung ofgelaf", "buy_now": "Elo kafen",
"invalid": "Ongëlteg Sëtzung" "view_cart": "Kuerf kucken",
}, "checkout": "Bezuelen",
"shipping": { "continue_shopping": "Weider akafen",
"title": "Liwweradress", "start_shopping": "Ufänken mat Akafen",
"select_address": "Adress auswielen", "empty_cart": "Äre Kuerf ass eidel",
"add_new": "Nei Adress derbäisetzen", "cart_total": "Kuerf Total",
"method": "Liwwermethod", "proceed_checkout": "Zur Bezuelung goen",
"select_method": "Liwwermethod auswielen", "payment": "Bezuelung",
"not_available": "Net verfügbar fir dës Adress" "place_order": "Bestellung opgi",
},
"payment": {
"title": "Bezuelung",
"method": "Bezuelungsmethod",
"required": "Bezuelung erfuerderlech",
"failed": "Bezuelung feelgeschloen"
},
"order": {
"summary": "Bestelliwwersiicht",
"subtotal": "Zwëschesumm",
"shipping": "Liwwerung",
"tax": "MwSt.",
"total": "Gesamtsumm",
"place_order": "Bestellung opginn"
},
"validation": {
"empty_cart": "Kuerf ass eidel",
"invalid_address": "Ongëlteg Liwweradress",
"insufficient_inventory": "Net genuch Bestand"
},
"messages": {
"order_placed": "Bestellung erfollegräich opginn", "order_placed": "Bestellung erfollegräich opginn",
"checkout_failed": "Checkout feelgeschloen", "thank_you": "Merci fir Är Bestellung",
"session_expired": "Är Sëtzung ass ofgelaf", "order_confirmation": "Bestellungsbestätegung"
"inventory_error": "E puer Artikelen sinn net méi verfügbar"
} }
} }

View File

@@ -30,7 +30,7 @@ from app.modules.customers.schemas import CustomerContext
from app.modules.orders.services import order_service from app.modules.orders.services import order_service
from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service from app.modules.messaging.services.email_service import EmailService # noqa: MOD-004 - Core email service
from middleware.vendor_context import require_vendor_context from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
from app.modules.orders.schemas import OrderCreate, OrderResponse from app.modules.orders.schemas import OrderCreate, OrderResponse
router = APIRouter() router = APIRouter()

View File

@@ -12,8 +12,8 @@ This is a self-contained module with:
- Templates: app.modules.cms.templates (namespaced as cms/) - Templates: app.modules.cms.templates (namespaced as cms/)
""" """
from app.modules.base import ModuleDefinition from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
def _get_admin_router(): def _get_admin_router():
@@ -53,6 +53,57 @@ cms_module = ModuleDefinition(
"media", # Media library "media", # Media library
], ],
}, },
# New module-driven menu definitions
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="contentMgmt",
label_key="cms.menu.content_management",
icon="document-text",
order=70,
items=[
MenuItemDefinition(
id="content-pages",
label_key="cms.menu.content_pages",
icon="document-text",
route="/admin/content-pages",
order=20,
),
MenuItemDefinition(
id="vendor-themes",
label_key="cms.menu.vendor_themes",
icon="color-swatch",
route="/admin/vendor-themes",
order=30,
),
],
),
],
FrontendType.VENDOR: [
MenuSectionDefinition(
id="shop",
label_key="cms.menu.shop_content",
icon="document-text",
order=40,
items=[
MenuItemDefinition(
id="content-pages",
label_key="cms.menu.content_pages",
icon="document-text",
route="/vendor/{vendor_code}/content-pages",
order=10,
),
MenuItemDefinition(
id="media",
label_key="cms.menu.media_library",
icon="photograph",
route="/vendor/{vendor_code}/media",
order=20,
),
],
),
],
},
is_core=True, # CMS is a core module - content management is fundamental is_core=True, # CMS is a core module - content management is fundamental
# Self-contained module configuration # Self-contained module configuration
is_self_contained=True, is_self_contained=True,

View File

@@ -1,126 +1,203 @@
{ {
"title": "Content-Verwaltung", "platform": {
"description": "Verwalten Sie Inhaltsseiten, Medienbibliothek und Händler-Themes", "nav": {
"pages": { "pricing": "Preise",
"title": "Inhaltsseiten", "find_shop": "Finden Sie Ihren Shop",
"subtitle": "Verwalten Sie Plattform- und Händler-Inhaltsseiten", "start_trial": "Kostenlos testen",
"create": "Seite erstellen", "admin_login": "Admin-Login",
"edit": "Seite bearbeiten", "vendor_login": "Händler-Login",
"delete": "Seite löschen", "toggle_menu": "Menü umschalten",
"list": "Alle Seiten", "toggle_dark_mode": "Dunkelmodus umschalten"
"empty": "Keine Seiten gefunden",
"empty_search": "Keine Seiten entsprechen Ihrer Suche",
"create_first": "Erste Seite erstellen"
}, },
"page": {
"title": "Seitentitel",
"slug": "Slug",
"slug_help": "URL-sichere Kennung (Kleinbuchstaben, Zahlen, Bindestriche)",
"content": "Inhalt",
"content_format": "Inhaltsformat",
"format_html": "HTML",
"format_markdown": "Markdown",
"platform": "Plattform",
"vendor_override": "Händler-Überschreibung",
"vendor_override_none": "Keine (Plattform-Standard)",
"vendor_override_help_default": "Dies ist eine plattformweite Standardseite",
"vendor_override_help_vendor": "Diese Seite überschreibt den Standard nur für den ausgewählten Händler"
},
"tiers": {
"platform": "Plattform-Marketing",
"vendor_default": "Händler-Standard",
"vendor_override": "Händler-Überschreibung"
},
"seo": {
"title": "SEO & Metadaten",
"meta_description": "Meta-Beschreibung",
"meta_description_help": "Zeichen (150-160 empfohlen)",
"meta_keywords": "Meta-Schlüsselwörter",
"meta_keywords_placeholder": "schlüsselwort1, schlüsselwort2, schlüsselwort3"
},
"navigation": {
"title": "Navigation & Anzeige",
"display_order": "Anzeigereihenfolge",
"display_order_help": "Niedriger = zuerst",
"show_in_header": "Im Header anzeigen",
"show_in_footer": "Im Footer anzeigen",
"show_in_legal": "Im Rechtsbereich anzeigen",
"show_in_legal_help": "Untere Leiste neben dem Copyright"
},
"publishing": {
"published": "Veröffentlicht",
"draft": "Entwurf",
"publish_help": "Diese Seite öffentlich sichtbar machen"
},
"homepage": {
"title": "Startseiten-Abschnitte",
"subtitle": "Mehrsprachiger Inhalt",
"loading": "Abschnitte werden geladen...",
"hero": { "hero": {
"title": "Hero-Abschnitt", "badge": "{trial_days}-Tage kostenlose Testversion - Keine Kreditkarte erforderlich",
"badge_text": "Badge-Text", "title": "Leichtes OMS für Letzshop-Verkäufer",
"main_title": "Titel", "subtitle": "Bestellverwaltung, Lager und Rechnungsstellung für den luxemburgischen E-Commerce. Schluss mit Tabellenkalkulationen. Führen Sie Ihr Geschäft.",
"subtitle": "Untertitel", "cta_trial": "Kostenlos testen",
"buttons": "Schaltflächen", "cta_find_shop": "Finden Sie Ihren Letzshop"
"add_button": "Schaltfläche hinzufügen"
},
"features": {
"title": "Funktionen-Abschnitt",
"section_title": "Abschnittstitel",
"cards": "Funktionskarten",
"add_card": "Karte hinzufügen",
"icon": "Icon-Name",
"feature_title": "Titel",
"feature_description": "Beschreibung"
}, },
"pricing": { "pricing": {
"title": "Preise-Abschnitt", "title": "Einfache, transparente Preise",
"section_title": "Abschnittstitel", "subtitle": "Wählen Sie den Plan, der zu Ihrem Unternehmen passt. Alle Pläne beinhalten eine {trial_days}-tägige kostenlose Testversion.",
"use_tiers": "Abonnement-Stufen aus der Datenbank verwenden", "monthly": "Monatlich",
"use_tiers_help": "Wenn aktiviert, werden Preiskarten dynamisch aus Ihrer Abonnement-Stufenkonfiguration abgerufen." "annual": "Jährlich",
"save_months": "Sparen Sie 2 Monate!",
"most_popular": "AM BELIEBTESTEN",
"recommended": "EMPFOHLEN",
"contact_sales": "Kontaktieren Sie uns",
"start_trial": "Kostenlos testen",
"per_month": "/Monat",
"per_year": "/Jahr",
"custom": "Individuell",
"orders_per_month": "{count} Bestellungen/Monat",
"unlimited_orders": "Unbegrenzte Bestellungen",
"products_limit": "{count} Produkte",
"unlimited_products": "Unbegrenzte Produkte",
"team_members": "{count} Teammitglieder",
"unlimited_team": "Unbegrenztes Team",
"letzshop_sync": "Letzshop-Synchronisierung",
"eu_vat_invoicing": "EU-MwSt-Rechnungen",
"analytics_dashboard": "Analyse-Dashboard",
"api_access": "API-Zugang",
"multi_channel": "Multi-Channel-Integration",
"products": "Produkte",
"team_member": "Teammitglied",
"unlimited": "Unbegrenzt",
"order_history": "Monate Bestellhistorie",
"trial_note": "Alle Pläne beinhalten eine {trial_days}-tägige kostenlose Testversion. Keine Kreditkarte erforderlich.",
"back_home": "Zurück zur Startseite"
},
"features": {
"letzshop_sync": "Letzshop-Synchronisierung",
"inventory_basic": "Grundlegende Lagerverwaltung",
"inventory_locations": "Lagerstandorte",
"inventory_purchase_orders": "Bestellungen",
"invoice_lu": "Luxemburg-MwSt-Rechnungen",
"invoice_eu_vat": "EU-MwSt-Rechnungen",
"invoice_bulk": "Massenrechnungen",
"customer_view": "Kundenliste",
"customer_export": "Kundenexport",
"analytics_dashboard": "Analyse-Dashboard",
"accounting_export": "Buchhaltungsexport",
"api_access": "API-Zugang",
"automation_rules": "Automatisierungsregeln",
"team_roles": "Teamrollen und Berechtigungen",
"white_label": "White-Label-Option",
"multi_vendor": "Multi-Händler-Unterstützung",
"custom_integrations": "Individuelle Integrationen",
"sla_guarantee": "SLA-Garantie",
"dedicated_support": "Dedizierter Kundenbetreuer"
},
"addons": {
"title": "Erweitern Sie Ihre Plattform",
"subtitle": "Fügen Sie Ihre Marke, professionelle E-Mail und erweiterte Sicherheit hinzu.",
"per_year": "/Jahr",
"per_month": "/Monat",
"custom_domain": "Eigene Domain",
"custom_domain_desc": "Nutzen Sie Ihre eigene Domain (meinedomain.com)",
"premium_ssl": "Premium SSL",
"premium_ssl_desc": "EV-Zertifikat für Vertrauenssiegel",
"email_package": "E-Mail-Paket",
"email_package_desc": "Professionelle E-Mail-Adressen"
},
"find_shop": {
"title": "Finden Sie Ihren Letzshop",
"subtitle": "Verkaufen Sie bereits auf Letzshop? Geben Sie Ihre Shop-URL ein, um zu beginnen.",
"placeholder": "Geben Sie Ihre Letzshop-URL ein (z.B. letzshop.lu/vendors/mein-shop)",
"button": "Meinen Shop finden",
"claim_shop": "Diesen Shop beanspruchen",
"already_claimed": "Bereits beansprucht",
"no_account": "Sie haben kein Letzshop-Konto?",
"signup_letzshop": "Registrieren Sie sich zuerst bei Letzshop",
"then_connect": ", dann kommen Sie zurück, um Ihren Shop zu verbinden.",
"search_placeholder": "Letzshop-URL oder Shopname eingeben...",
"search_button": "Suchen",
"examples": "Beispiele:",
"claim_button": "Diesen Shop beanspruchen und kostenlos testen",
"not_found": "Wir konnten keinen Letzshop mit dieser URL finden. Bitte überprüfen Sie und versuchen Sie es erneut.",
"or_signup": "Oder registrieren Sie sich ohne Letzshop-Verbindung",
"need_help": "Brauchen Sie Hilfe?",
"no_account_yet": "Sie haben noch kein Letzshop-Konto? Kein Problem!",
"create_letzshop": "Letzshop-Konto erstellen",
"signup_without": "Ohne Letzshop registrieren",
"looking_up": "Suche Ihren Shop...",
"found": "Gefunden:",
"claimed_badge": "Bereits beansprucht"
},
"signup": {
"step_plan": "Plan wählen",
"step_shop": "Shop beanspruchen",
"step_account": "Konto",
"step_payment": "Zahlung",
"choose_plan": "Wählen Sie Ihren Plan",
"save_percent": "Sparen Sie {percent}%",
"trial_info": "Wir erfassen Ihre Zahlungsdaten, aber Sie werden erst nach Ende der Testphase belastet.",
"connect_shop": "Verbinden Sie Ihren Letzshop",
"connect_optional": "Optional: Verknüpfen Sie Ihr Letzshop-Konto, um Bestellungen automatisch zu synchronisieren.",
"connect_continue": "Verbinden und fortfahren",
"skip_step": "Diesen Schritt überspringen",
"create_account": "Erstellen Sie Ihr Konto",
"first_name": "Vorname",
"last_name": "Nachname",
"company_name": "Firmenname",
"email": "E-Mail",
"password": "Passwort",
"password_hint": "Mindestens 8 Zeichen",
"continue": "Weiter",
"continue_payment": "Weiter zur Zahlung",
"back": "Zurück",
"add_payment": "Zahlungsmethode hinzufügen",
"no_charge_note": "Sie werden erst nach Ablauf Ihrer {trial_days}-tägigen Testphase belastet.",
"processing": "Verarbeitung...",
"start_trial": "Kostenlose Testversion starten",
"creating_account": "Erstelle Ihr Konto..."
},
"success": {
"title": "Willkommen bei Wizamart!",
"subtitle": "Ihr Konto wurde erstellt und Ihre {trial_days}-tägige kostenlose Testphase hat begonnen.",
"what_next": "Was kommt als Nächstes?",
"step_connect": "Letzshop verbinden:",
"step_connect_desc": "Fügen Sie Ihren API-Schlüssel hinzu, um Bestellungen automatisch zu synchronisieren.",
"step_invoicing": "Rechnungsstellung einrichten:",
"step_invoicing_desc": "Konfigurieren Sie Ihre Rechnungseinstellungen für die luxemburgische Compliance.",
"step_products": "Produkte importieren:",
"step_products_desc": "Synchronisieren Sie Ihren Produktkatalog von Letzshop.",
"go_to_dashboard": "Zum Dashboard",
"login_dashboard": "Zum Dashboard anmelden",
"need_help": "Brauchen Sie Hilfe beim Einstieg?",
"contact_support": "Kontaktieren Sie unser Support-Team"
}, },
"cta": { "cta": {
"title": "Call-to-Action-Abschnitt", "title": "Bereit, Ihre Bestellungen zu optimieren?",
"main_title": "Titel", "subtitle": "Schließen Sie sich Letzshop-Händlern an, die Wizamart für ihre Bestellverwaltung vertrauen. Starten Sie heute Ihre {trial_days}-tägige kostenlose Testversion.",
"subtitle": "Untertitel", "button": "Kostenlos testen"
"buttons": "Schaltflächen",
"add_button": "Schaltfläche hinzufügen"
}
}, },
"media": { "footer": {
"title": "Medienbibliothek", "tagline": "Leichtes OMS für Letzshop-Verkäufer. Verwalten Sie Bestellungen, Lager und Rechnungen.",
"upload": "Hochladen", "quick_links": "Schnelllinks",
"upload_file": "Datei hochladen", "platform": "Plattform",
"delete": "Löschen", "contact": "Kontakt",
"empty": "Keine Mediendateien", "copyright": "© {year} Wizamart. Entwickelt für den luxemburgischen E-Commerce.",
"upload_first": "Laden Sie Ihre erste Datei hoch" "privacy": "Datenschutzerklärung",
"terms": "Nutzungsbedingungen",
"about": "Über uns",
"faq": "FAQ",
"contact_us": "Kontaktieren Sie uns"
}, },
"themes": { "modern": {
"title": "Händler-Themes", "badge_integration": "Offizielle Integration",
"subtitle": "Verwalten Sie Händler-Theme-Anpassungen" "badge_connect": "In 2 Minuten verbinden",
}, "hero_title_1": "Für den luxemburgischen E-Commerce entwickelt",
"actions": { "hero_title_2": "Das Back-Office, das Letzshop Ihnen nicht gibt",
"save": "Speichern", "hero_subtitle": "Synchronisieren Sie Bestellungen, verwalten Sie Lager, erstellen Sie Rechnungen mit korrekter MwSt und besitzen Sie Ihre Kundendaten. Alles an einem Ort.",
"saving": "Speichern...", "cta_trial": "{trial_days}-Tage kostenlos testen",
"update": "Seite aktualisieren", "cta_how": "Sehen Sie, wie es funktioniert",
"create": "Seite erstellen", "hero_note": "Keine Kreditkarte erforderlich. Einrichtung in 5 Minuten. Jederzeit kündbar.",
"cancel": "Abbrechen", "pain_title": "Kommt Ihnen das bekannt vor?",
"back_to_list": "Zurück zur Liste", "pain_subtitle": "Das sind die täglichen Frustrationen von Letzshop-Verkäufern",
"preview": "Vorschau", "pain_manual": "Manuelle Bestelleingabe",
"revert_to_default": "Auf Standard zurücksetzen" "pain_manual_desc": "Bestellungen von Letzshop in Tabellenkalkulationen kopieren. Jeden. Einzelnen. Tag.",
}, "pain_inventory": "Lagerchaos",
"messages": { "pain_inventory_desc": "Der Bestand in Letzshop stimmt nicht mit der Realität überein. Überverkäufe passieren.",
"created": "Seite erfolgreich erstellt", "pain_vat": "Falsche MwSt-Rechnungen",
"updated": "Seite erfolgreich aktualisiert", "pain_vat_desc": "EU-Kunden brauchen die korrekte MwSt. Ihr Buchhalter beschwert sich.",
"deleted": "Seite erfolgreich gelöscht", "pain_customers": "Verlorene Kunden",
"reverted": "Auf Standardseite zurückgesetzt", "pain_customers_desc": "Letzshop besitzt Ihre Kundendaten. Sie können nicht retargeten oder Loyalität aufbauen.",
"error_loading": "Fehler beim Laden der Seite", "how_title": "So funktioniert es",
"error_saving": "Fehler beim Speichern der Seite", "how_subtitle": "Vom Chaos zur Kontrolle in 4 Schritten",
"confirm_delete": "Sind Sie sicher, dass Sie diese Seite löschen möchten?" "how_step1": "Letzshop verbinden",
}, "how_step1_desc": "Geben Sie Ihre Letzshop-API-Zugangsdaten ein. In 2 Minuten erledigt, keine technischen Kenntnisse erforderlich.",
"filters": { "how_step2": "Bestellungen kommen rein",
"all_pages": "Alle Seiten", "how_step2_desc": "Bestellungen werden automatisch synchronisiert. Bestätigen und Tracking direkt von Wizamart hinzufügen.",
"all_platforms": "Alle Plattformen", "how_step3": "Rechnungen erstellen",
"search_placeholder": "Seiten suchen..." "how_step3_desc": "Ein Klick, um konforme PDF-Rechnungen mit korrekter MwSt für jedes EU-Land zu erstellen.",
"how_step4": "Ihr Geschäft ausbauen",
"how_step4_desc": "Exportieren Sie Kunden für Marketing. Verfolgen Sie Lagerbestände. Konzentrieren Sie sich auf den Verkauf, nicht auf Tabellenkalkulationen.",
"features_title": "Alles, was ein Letzshop-Verkäufer braucht",
"features_subtitle": "Die operativen Tools, die Letzshop nicht bietet",
"cta_final_title": "Bereit, die Kontrolle über Ihr Letzshop-Geschäft zu übernehmen?",
"cta_final_subtitle": "Schließen Sie sich luxemburgischen Händlern an, die aufgehört haben, gegen Tabellenkalkulationen zu kämpfen, und begonnen haben, ihr Geschäft auszubauen.",
"cta_final_note": "Keine Kreditkarte erforderlich. Einrichtung in 5 Minuten. Volle Professional-Funktionen während der Testphase."
}
} }
} }

View File

@@ -1,126 +1,203 @@
{ {
"title": "Gestion de contenu", "platform": {
"description": "Gestion des pages de contenu, de la bibliothèque de médias et des thèmes", "nav": {
"pages": { "pricing": "Tarifs",
"title": "Pages de contenu", "find_shop": "Trouvez votre boutique",
"subtitle": "Gérez les pages de contenu de la plateforme et des vendeurs", "start_trial": "Essai gratuit",
"create": "Créer une page", "admin_login": "Connexion Admin",
"edit": "Modifier la page", "vendor_login": "Connexion Vendeur",
"delete": "Supprimer la page", "toggle_menu": "Basculer le menu",
"list": "Toutes les pages", "toggle_dark_mode": "Basculer le mode sombre"
"empty": "Aucune page trouvée",
"empty_search": "Aucune page ne correspond à votre recherche",
"create_first": "Créer la première page"
}, },
"page": {
"title": "Titre de la page",
"slug": "Slug",
"slug_help": "Identifiant URL (minuscules, chiffres, tirets uniquement)",
"content": "Contenu",
"content_format": "Format du contenu",
"format_html": "HTML",
"format_markdown": "Markdown",
"platform": "Plateforme",
"vendor_override": "Remplacement vendeur",
"vendor_override_none": "Aucun (page par défaut)",
"vendor_override_help_default": "Ceci est une page par défaut pour toute la plateforme",
"vendor_override_help_vendor": "Cette page remplace la page par défaut pour le vendeur sélectionné"
},
"tiers": {
"platform": "Marketing plateforme",
"vendor_default": "Défaut vendeur",
"vendor_override": "Remplacement vendeur"
},
"seo": {
"title": "SEO & Métadonnées",
"meta_description": "Meta Description",
"meta_description_help": "caractères (150-160 recommandés)",
"meta_keywords": "Mots-clés",
"meta_keywords_placeholder": "mot-clé1, mot-clé2, mot-clé3"
},
"navigation": {
"title": "Navigation & Affichage",
"display_order": "Ordre d'affichage",
"display_order_help": "Plus bas = premier",
"show_in_header": "Afficher dans l'en-tête",
"show_in_footer": "Afficher dans le pied de page",
"show_in_legal": "Afficher dans les mentions légales",
"show_in_legal_help": "Barre en bas à côté du copyright"
},
"publishing": {
"published": "Publié",
"draft": "Brouillon",
"publish_help": "Rendre cette page visible au public"
},
"homepage": {
"title": "Sections de la page d'accueil",
"subtitle": "Contenu multilingue",
"loading": "Chargement des sections...",
"hero": { "hero": {
"title": "Section Hero", "badge": "Essai gratuit de {trial_days} jours - Aucune carte de crédit requise",
"badge_text": "Texte du badge", "title": "OMS léger pour les vendeurs Letzshop",
"main_title": "Titre", "subtitle": "Gestion des commandes, stocks et facturation conçue pour le e-commerce luxembourgeois. Arrêtez de jongler avec les tableurs. Gérez votre entreprise.",
"subtitle": "Sous-titre", "cta_trial": "Essai gratuit",
"buttons": "Boutons", "cta_find_shop": "Trouvez votre boutique Letzshop"
"add_button": "Ajouter un bouton"
},
"features": {
"title": "Section Fonctionnalités",
"section_title": "Titre de la section",
"cards": "Cartes de fonctionnalités",
"add_card": "Ajouter une carte",
"icon": "Nom de l'icône",
"feature_title": "Titre",
"feature_description": "Description"
}, },
"pricing": { "pricing": {
"title": "Section Tarifs", "title": "Tarification simple et transparente",
"section_title": "Titre de la section", "subtitle": "Choisissez le plan adapté à votre entreprise. Tous les plans incluent un essai gratuit de {trial_days} jours.",
"use_tiers": "Utiliser les niveaux d'abonnement de la base de données", "monthly": "Mensuel",
"use_tiers_help": "Si activé, les cartes de tarifs sont extraites dynamiquement de la configuration des niveaux d'abonnement." "annual": "Annuel",
"save_months": "Économisez 2 mois !",
"most_popular": "LE PLUS POPULAIRE",
"recommended": "RECOMMANDÉ",
"contact_sales": "Contactez-nous",
"start_trial": "Essai gratuit",
"per_month": "/mois",
"per_year": "/an",
"custom": "Sur mesure",
"orders_per_month": "{count} commandes/mois",
"unlimited_orders": "Commandes illimitées",
"products_limit": "{count} produits",
"unlimited_products": "Produits illimités",
"team_members": "{count} membres d'équipe",
"unlimited_team": "Équipe illimitée",
"letzshop_sync": "Synchronisation Letzshop",
"eu_vat_invoicing": "Facturation TVA UE",
"analytics_dashboard": "Tableau de bord analytique",
"api_access": "Accès API",
"multi_channel": "Intégration multi-canal",
"products": "produits",
"team_member": "membre d'équipe",
"unlimited": "Illimité",
"order_history": "mois d'historique",
"trial_note": "Tous les plans incluent un essai gratuit de {trial_days} jours. Aucune carte de crédit requise.",
"back_home": "Retour à l'accueil"
},
"features": {
"letzshop_sync": "Synchronisation Letzshop",
"inventory_basic": "Gestion de stock de base",
"inventory_locations": "Emplacements d'entrepôt",
"inventory_purchase_orders": "Bons de commande",
"invoice_lu": "Facturation TVA Luxembourg",
"invoice_eu_vat": "Facturation TVA UE",
"invoice_bulk": "Facturation en masse",
"customer_view": "Liste des clients",
"customer_export": "Export clients",
"analytics_dashboard": "Tableau de bord analytique",
"accounting_export": "Export comptable",
"api_access": "Accès API",
"automation_rules": "Règles d'automatisation",
"team_roles": "Rôles et permissions",
"white_label": "Option marque blanche",
"multi_vendor": "Support multi-vendeurs",
"custom_integrations": "Intégrations personnalisées",
"sla_guarantee": "Garantie SLA",
"dedicated_support": "Gestionnaire de compte dédié"
},
"addons": {
"title": "Améliorez votre plateforme",
"subtitle": "Ajoutez votre marque, e-mail professionnel et sécurité renforcée.",
"per_year": "/an",
"per_month": "/mois",
"custom_domain": "Domaine personnalisé",
"custom_domain_desc": "Utilisez votre propre domaine (mondomaine.com)",
"premium_ssl": "SSL Premium",
"premium_ssl_desc": "Certificat EV pour les badges de confiance",
"email_package": "Pack Email",
"email_package_desc": "Adresses e-mail professionnelles"
},
"find_shop": {
"title": "Trouvez votre boutique Letzshop",
"subtitle": "Vous vendez déjà sur Letzshop ? Entrez l'URL de votre boutique pour commencer.",
"placeholder": "Entrez votre URL Letzshop (ex: letzshop.lu/vendors/ma-boutique)",
"button": "Trouver ma boutique",
"claim_shop": "Réclamer cette boutique",
"already_claimed": "Déjà réclamée",
"no_account": "Vous n'avez pas de compte Letzshop ?",
"signup_letzshop": "Inscrivez-vous d'abord sur Letzshop",
"then_connect": ", puis revenez connecter votre boutique.",
"search_placeholder": "Entrez l'URL Letzshop ou le nom de la boutique...",
"search_button": "Rechercher",
"examples": "Exemples :",
"claim_button": "Réclamez cette boutique et démarrez l'essai gratuit",
"not_found": "Nous n'avons pas trouvé de boutique Letzshop avec cette URL. Vérifiez et réessayez.",
"or_signup": "Ou inscrivez-vous sans connexion Letzshop",
"need_help": "Besoin d'aide ?",
"no_account_yet": "Vous n'avez pas encore de compte Letzshop ? Pas de problème !",
"create_letzshop": "Créer un compte Letzshop",
"signup_without": "S'inscrire sans Letzshop",
"looking_up": "Recherche de votre boutique...",
"found": "Trouvé :",
"claimed_badge": "Déjà réclamée"
},
"signup": {
"step_plan": "Choisir le plan",
"step_shop": "Réclamer la boutique",
"step_account": "Compte",
"step_payment": "Paiement",
"choose_plan": "Choisissez votre plan",
"save_percent": "Économisez {percent}%",
"trial_info": "Nous collecterons vos informations de paiement, mais vous ne serez pas débité avant la fin de l'essai.",
"connect_shop": "Connectez votre boutique Letzshop",
"connect_optional": "Optionnel : Liez votre compte Letzshop pour synchroniser automatiquement les commandes.",
"connect_continue": "Connecter et continuer",
"skip_step": "Passer cette étape",
"create_account": "Créez votre compte",
"first_name": "Prénom",
"last_name": "Nom",
"company_name": "Nom de l'entreprise",
"email": "E-mail",
"password": "Mot de passe",
"password_hint": "Minimum 8 caractères",
"continue": "Continuer",
"continue_payment": "Continuer vers le paiement",
"back": "Retour",
"add_payment": "Ajouter un moyen de paiement",
"no_charge_note": "Vous ne serez pas débité avant la fin de votre essai de {trial_days} jours.",
"processing": "Traitement en cours...",
"start_trial": "Démarrer l'essai gratuit",
"creating_account": "Création de votre compte..."
},
"success": {
"title": "Bienvenue sur Wizamart !",
"subtitle": "Votre compte a été créé et votre essai gratuit de {trial_days} jours a commencé.",
"what_next": "Et maintenant ?",
"step_connect": "Connecter Letzshop :",
"step_connect_desc": "Ajoutez votre clé API pour commencer à synchroniser automatiquement les commandes.",
"step_invoicing": "Configurer la facturation :",
"step_invoicing_desc": "Configurez vos paramètres de facturation pour la conformité luxembourgeoise.",
"step_products": "Importer les produits :",
"step_products_desc": "Synchronisez votre catalogue de produits depuis Letzshop.",
"go_to_dashboard": "Aller au tableau de bord",
"login_dashboard": "Connexion au tableau de bord",
"need_help": "Besoin d'aide pour démarrer ?",
"contact_support": "Contactez notre équipe support"
}, },
"cta": { "cta": {
"title": "Section Appel à l'action", "title": "Prêt à optimiser vos commandes ?",
"main_title": "Titre", "subtitle": "Rejoignez les vendeurs Letzshop qui font confiance à Wizamart pour leur gestion de commandes. Commencez votre essai gratuit de {trial_days} jours aujourd'hui.",
"subtitle": "Sous-titre", "button": "Essai gratuit"
"buttons": "Boutons",
"add_button": "Ajouter un bouton"
}
}, },
"media": { "footer": {
"title": "Bibliothèque de médias", "tagline": "OMS léger pour les vendeurs Letzshop. Gérez commandes, stocks et facturation.",
"upload": "Télécharger", "quick_links": "Liens rapides",
"upload_file": "Télécharger un fichier", "platform": "Plateforme",
"delete": "Supprimer", "contact": "Contact",
"empty": "Aucun fichier média", "copyright": "© {year} Wizamart. Conçu pour le e-commerce luxembourgeois.",
"upload_first": "Téléchargez votre premier fichier" "privacy": "Politique de confidentialité",
"terms": "Conditions d'utilisation",
"about": "À propos",
"faq": "FAQ",
"contact_us": "Nous contacter"
}, },
"themes": { "modern": {
"title": "Thèmes vendeurs", "badge_integration": "Intégration officielle",
"subtitle": "Gérez les personnalisations de thèmes des vendeurs" "badge_connect": "Connexion en 2 minutes",
}, "hero_title_1": "Conçu pour le e-commerce luxembourgeois",
"actions": { "hero_title_2": "Le back-office que Letzshop ne vous donne pas",
"save": "Enregistrer", "hero_subtitle": "Synchronisez les commandes, gérez les stocks, générez des factures avec la TVA correcte et possédez vos données clients. Tout en un seul endroit.",
"saving": "Enregistrement...", "cta_trial": "Essai gratuit de {trial_days} jours",
"update": "Mettre à jour la page", "cta_how": "Voir comment ça marche",
"create": "Créer la page", "hero_note": "Aucune carte de crédit requise. Configuration en 5 minutes. Annulez à tout moment.",
"cancel": "Annuler", "pain_title": "Ça vous dit quelque chose ?",
"back_to_list": "Retour à la liste", "pain_subtitle": "Ce sont les frustrations quotidiennes des vendeurs Letzshop",
"preview": "Aperçu", "pain_manual": "Saisie manuelle des commandes",
"revert_to_default": "Revenir à la valeur par défaut" "pain_manual_desc": "Copier-coller les commandes de Letzshop vers des tableurs. Chaque. Jour.",
}, "pain_inventory": "Chaos des stocks",
"messages": { "pain_inventory_desc": "Le stock dans Letzshop ne correspond pas à la réalité. Les surventes arrivent.",
"created": "Page créée avec succès", "pain_vat": "Mauvaises factures TVA",
"updated": "Page mise à jour avec succès", "pain_vat_desc": "Les clients UE ont besoin de la TVA correcte. Votre comptable se plaint.",
"deleted": "Page supprimée avec succès", "pain_customers": "Clients perdus",
"reverted": "Retour à la page par défaut", "pain_customers_desc": "Letzshop possède vos données clients. Vous ne pouvez pas les recibler ou fidéliser.",
"error_loading": "Erreur lors du chargement de la page", "how_title": "Comment ça marche",
"error_saving": "Erreur lors de l'enregistrement de la page", "how_subtitle": "Du chaos au contrôle en 4 étapes",
"confirm_delete": "Êtes-vous sûr de vouloir supprimer cette page ?" "how_step1": "Connecter Letzshop",
}, "how_step1_desc": "Entrez vos identifiants API Letzshop. Fait en 2 minutes, aucune compétence technique requise.",
"filters": { "how_step2": "Les commandes arrivent",
"all_pages": "Toutes les pages", "how_step2_desc": "Les commandes se synchronisent automatiquement. Confirmez et ajoutez le suivi directement depuis Wizamart.",
"all_platforms": "Toutes les plateformes", "how_step3": "Générer des factures",
"search_placeholder": "Rechercher des pages..." "how_step3_desc": "Un clic pour créer des factures PDF conformes avec la TVA correcte pour tout pays UE.",
"how_step4": "Développez votre entreprise",
"how_step4_desc": "Exportez les clients pour le marketing. Suivez les stocks. Concentrez-vous sur la vente, pas les tableurs.",
"features_title": "Tout ce dont un vendeur Letzshop a besoin",
"features_subtitle": "Les outils opérationnels que Letzshop ne fournit pas",
"cta_final_title": "Prêt à prendre le contrôle de votre entreprise Letzshop ?",
"cta_final_subtitle": "Rejoignez les vendeurs luxembourgeois qui ont arrêté de lutter contre les tableurs et ont commencé à développer leur entreprise.",
"cta_final_note": "Aucune carte de crédit requise. Configuration en 5 minutes. Toutes les fonctionnalités Pro pendant l'essai."
}
} }
} }

View File

@@ -1,126 +1,203 @@
{ {
"title": "Inhalts-Verwaltung", "platform": {
"description": "Verwaltet Inhaltsäiten, Mediebibliothéik an Händler-Themen", "nav": {
"pages": { "pricing": "Präisser",
"title": "Inhaltsäiten", "find_shop": "Fannt Äre Buttek",
"subtitle": "Verwaltet Plattform- an Händler-Inhaltsäiten", "start_trial": "Gratis Testen",
"create": "Säit erstellen", "admin_login": "Admin Login",
"edit": "Säit änneren", "vendor_login": "Händler Login",
"delete": "Säit läschen", "toggle_menu": "Menü wiesselen",
"list": "All Säiten", "toggle_dark_mode": "Däischter Modus wiesselen"
"empty": "Keng Säite fonnt",
"empty_search": "Keng Säite passen op Är Sich",
"create_first": "Éischt Säit erstellen"
}, },
"page": {
"title": "Säitentitel",
"slug": "Slug",
"slug_help": "URL-sécher Kennung (Klengbuschtawen, Zuelen, Bindestricher)",
"content": "Inhalt",
"content_format": "Inhaltsformat",
"format_html": "HTML",
"format_markdown": "Markdown",
"platform": "Plattform",
"vendor_override": "Händler-Iwwerschreiwung",
"vendor_override_none": "Keng (Plattform-Standard)",
"vendor_override_help_default": "Dëst ass eng plattformwäit Standardsäit",
"vendor_override_help_vendor": "Dës Säit iwwerschreift de Standard nëmme fir de gewielte Händler"
},
"tiers": {
"platform": "Plattform-Marketing",
"vendor_default": "Händler-Standard",
"vendor_override": "Händler-Iwwerschreiwung"
},
"seo": {
"title": "SEO & Metadaten",
"meta_description": "Meta-Beschreiwung",
"meta_description_help": "Zeechen (150-160 recommandéiert)",
"meta_keywords": "Meta-Schlësselwierder",
"meta_keywords_placeholder": "schlësselwuert1, schlësselwuert2, schlësselwuert3"
},
"navigation": {
"title": "Navigatioun & Affichage",
"display_order": "Uweisungsreiefolleg",
"display_order_help": "Méi niddreg = éischt",
"show_in_header": "Am Header weisen",
"show_in_footer": "Am Footer weisen",
"show_in_legal": "Am Rechtsberäich weisen",
"show_in_legal_help": "Ënnescht Leist nieft dem Copyright"
},
"publishing": {
"published": "Verëffentlecht",
"draft": "Entworf",
"publish_help": "Dës Säit ëffentlech siichtbar maachen"
},
"homepage": {
"title": "Haaptsäit-Sektiounen",
"subtitle": "Méisproochegen Inhalt",
"loading": "Sektiounen ginn gelueden...",
"hero": { "hero": {
"title": "Hero-Sektioun", "badge": "{trial_days}-Deeg gratis Testversioun - Keng Kreditkaart néideg",
"badge_text": "Badge-Text", "title": "Liichtt OMS fir Letzshop Verkeefer",
"main_title": "Titel", "subtitle": "Bestellungsverwaltung, Lager an Rechnungsstellung fir de lëtzebuergeschen E-Commerce. Schluss mat Tabellen. Féiert Äert Geschäft.",
"subtitle": "Ënnertitel", "cta_trial": "Gratis Testen",
"buttons": "Knäpp", "cta_find_shop": "Fannt Äre Letzshop Buttek"
"add_button": "Knapp derbäisetzen"
},
"features": {
"title": "Funktiounen-Sektioun",
"section_title": "Sektiounstitel",
"cards": "Funktiounskaarten",
"add_card": "Kaart derbäisetzen",
"icon": "Icon-Numm",
"feature_title": "Titel",
"feature_description": "Beschreiwung"
}, },
"pricing": { "pricing": {
"title": "Präisser-Sektioun", "title": "Einfach, transparent Präisser",
"section_title": "Sektiounstitel", "subtitle": "Wielt de Plang deen zu Ärer Firma passt. All Pläng enthalen eng {trial_days}-Deeg gratis Testversioun.",
"use_tiers": "Abonnement-Stufen aus der Datebank benotzen", "monthly": "Monatslech",
"use_tiers_help": "Wann aktivéiert, ginn d'Präiskaarten dynamesch aus Ärer Abonnement-Stufekonfiguratioun ofgeruff." "annual": "Jäerlech",
"save_months": "Spuert 2 Méint!",
"most_popular": "AM BELÉIFSTEN",
"recommended": "EMPFOHLEN",
"contact_sales": "Kontaktéiert eis",
"start_trial": "Gratis Testen",
"per_month": "/Mount",
"per_year": "/Joer",
"custom": "Personnaliséiert",
"orders_per_month": "{count} Bestellungen/Mount",
"unlimited_orders": "Onbegrenzt Bestellungen",
"products_limit": "{count} Produkter",
"unlimited_products": "Onbegrenzt Produkter",
"team_members": "{count} Teammemberen",
"unlimited_team": "Onbegrenzt Team",
"letzshop_sync": "Letzshop Synchronisatioun",
"eu_vat_invoicing": "EU TVA Rechnungen",
"analytics_dashboard": "Analyse Dashboard",
"api_access": "API Zougang",
"multi_channel": "Multi-Channel Integratioun",
"products": "Produkter",
"team_member": "Teammember",
"unlimited": "Onbegrenzt",
"order_history": "Méint Bestellungshistorique",
"trial_note": "All Pläng enthalen eng {trial_days}-Deeg gratis Testversioun. Keng Kreditkaart néideg.",
"back_home": "Zréck op d'Haaptsäit"
},
"features": {
"letzshop_sync": "Letzshop Synchronisatioun",
"inventory_basic": "Basis Lagerverwaltung",
"inventory_locations": "Lagerstanduerten",
"inventory_purchase_orders": "Bestellungen",
"invoice_lu": "Lëtzebuerg TVA Rechnungen",
"invoice_eu_vat": "EU TVA Rechnungen",
"invoice_bulk": "Massrechnungen",
"customer_view": "Clientelëscht",
"customer_export": "Client Export",
"analytics_dashboard": "Analyse Dashboard",
"accounting_export": "Comptabilitéits Export",
"api_access": "API Zougang",
"automation_rules": "Automatiséierungsreegelen",
"team_roles": "Team Rollen an Autorisatiounen",
"white_label": "White-Label Optioun",
"multi_vendor": "Multi-Händler Ënnerstëtzung",
"custom_integrations": "Personnaliséiert Integratiounen",
"sla_guarantee": "SLA Garantie",
"dedicated_support": "Dedizéierte Kontobetreier"
},
"addons": {
"title": "Erweidert Är Plattform",
"subtitle": "Füügt Är Mark, professionell Email a verbessert Sécherheet derbäi.",
"per_year": "/Joer",
"per_month": "/Mount",
"custom_domain": "Eegen Domain",
"custom_domain_desc": "Benotzt Är eegen Domain (mengdomain.lu)",
"premium_ssl": "Premium SSL",
"premium_ssl_desc": "EV Zertifikat fir Vertrauensbadgen",
"email_package": "Email Package",
"email_package_desc": "Professionell Email Adressen"
},
"find_shop": {
"title": "Fannt Äre Letzshop Buttek",
"subtitle": "Verkaaft Dir schonn op Letzshop? Gitt Är Buttek URL an fir unzefänken.",
"placeholder": "Gitt Är Letzshop URL an (z.B. letzshop.lu/vendors/mäi-buttek)",
"button": "Mäi Buttek fannen",
"claim_shop": "Dëse Buttek reklaméieren",
"already_claimed": "Scho reklaméiert",
"no_account": "Kee Letzshop Kont?",
"signup_letzshop": "Registréiert Iech éischt bei Letzshop",
"then_connect": ", dann kommt zréck fir Äre Buttek ze verbannen.",
"search_placeholder": "Letzshop URL oder Butteknumm aginn...",
"search_button": "Sichen",
"examples": "Beispiller:",
"claim_button": "Dëse Buttek reklaméieren a gratis testen",
"not_found": "Mir konnten keen Letzshop Buttek mat dëser URL fannen. Iwwerpréift w.e.g. a probéiert nach eng Kéier.",
"or_signup": "Oder registréiert Iech ouni Letzshop Verbindung",
"need_help": "Braucht Dir Hëllef?",
"no_account_yet": "Dir hutt nach keen Letzshop Kont? Keen Problem!",
"create_letzshop": "Letzshop Kont erstellen",
"signup_without": "Ouni Letzshop registréieren",
"looking_up": "Sich Äre Buttek...",
"found": "Fonnt:",
"claimed_badge": "Scho reklaméiert"
},
"signup": {
"step_plan": "Plang wielen",
"step_shop": "Buttek reklaméieren",
"step_account": "Kont",
"step_payment": "Bezuelung",
"choose_plan": "Wielt Äre Plang",
"save_percent": "Spuert {percent}%",
"trial_info": "Mir sammelen Är Bezuelungsinformatiounen, awer Dir gitt eréischt nom Enn vun der Testperiod belaaschtt.",
"connect_shop": "Verbannt Äre Letzshop Buttek",
"connect_optional": "Optional: Verlinkt Äre Letzshop Kont fir Bestellungen automatesch ze synchroniséieren.",
"connect_continue": "Verbannen a weider",
"skip_step": "Dëse Schrëtt iwwersprangen",
"create_account": "Erstellt Äre Kont",
"first_name": "Virnumm",
"last_name": "Numm",
"company_name": "Firmennumm",
"email": "Email",
"password": "Passwuert",
"password_hint": "Mindestens 8 Zeechen",
"continue": "Weider",
"continue_payment": "Weider zur Bezuelung",
"back": "Zréck",
"add_payment": "Bezuelungsmethod derbäisetzen",
"no_charge_note": "Dir gitt eréischt nom Enn vun Ärer {trial_days}-Deeg Testperiod belaaschtt.",
"processing": "Veraarbechtung...",
"start_trial": "Gratis Testversioun starten",
"creating_account": "Erstellt Äre Kont..."
},
"success": {
"title": "Wëllkomm bei Wizamart!",
"subtitle": "Äre Kont gouf erstallt an Är {trial_days}-Deeg gratis Testversioun huet ugefaang.",
"what_next": "Wat kënnt duerno?",
"step_connect": "Letzshop verbannen:",
"step_connect_desc": "Füügt Äre API Schlëssel derbäi fir Bestellungen automatesch ze synchroniséieren.",
"step_invoicing": "Rechnungsstellung astellen:",
"step_invoicing_desc": "Konfiguréiert Är Rechnungsastellungen fir Lëtzebuerger Konformitéit.",
"step_products": "Produkter importéieren:",
"step_products_desc": "Synchroniséiert Äre Produktkatalog vu Letzshop.",
"go_to_dashboard": "Zum Dashboard",
"login_dashboard": "Am Dashboard umellen",
"need_help": "Braucht Dir Hëllef beim Ufänken?",
"contact_support": "Kontaktéiert eist Support Team"
}, },
"cta": { "cta": {
"title": "Call-to-Action-Sektioun", "title": "Prett fir Är Bestellungen ze optiméieren?",
"main_title": "Titel", "subtitle": "Schléisst Iech Letzshop Händler un déi Wizamart fir hir Bestellungsverwaltung vertrauen. Fänkt haut Är {trial_days}-Deeg gratis Testversioun un.",
"subtitle": "Ënnertitel", "button": "Gratis Testen"
"buttons": "Knäpp",
"add_button": "Knapp derbäisetzen"
}
}, },
"media": { "footer": {
"title": "Mediebibliothéik", "tagline": "Liichtt OMS fir Letzshop Verkeefer. Verwaltt Bestellungen, Lager an Rechnungen.",
"upload": "Eroplueden", "quick_links": "Séier Linken",
"upload_file": "Fichier eroplueden", "platform": "Plattform",
"delete": "Läschen", "contact": "Kontakt",
"empty": "Keng Mediefichieren", "copyright": "© {year} Wizamart. Gemaach fir de lëtzebuergeschen E-Commerce.",
"upload_first": "Luet Äre éischte Fichier erop" "privacy": "Dateschutzrichtlinn",
"terms": "Notzungsbedéngungen",
"about": "Iwwer eis",
"faq": "FAQ",
"contact_us": "Kontaktéiert eis"
}, },
"themes": { "modern": {
"title": "Händler-Themen", "badge_integration": "Offiziell Integratioun",
"subtitle": "Verwaltet Händler-Theme-Personnalisatiounen" "badge_connect": "An 2 Minutten verbannen",
}, "hero_title_1": "Gemaach fir de lëtzebuergeschen E-Commerce",
"actions": { "hero_title_2": "De Back-Office dee Letzshop Iech net gëtt",
"save": "Späicheren", "hero_subtitle": "Synchroniséiert Bestellungen, verwaltt Lager, erstellt Rechnunge mat der korrekter TVA a besëtzt Är Clientsdaten. Alles un engem Plaz.",
"saving": "Späicheren...", "cta_trial": "{trial_days}-Deeg gratis testen",
"update": "Säit aktualiséieren", "cta_how": "Kuckt wéi et funktionéiert",
"create": "Säit erstellen", "hero_note": "Keng Kreditkaart néideg. Setup an 5 Minutten. Ëmmer kënnegen.",
"cancel": "Ofbriechen", "pain_title": "Kënnt Iech dat bekannt vir?",
"back_to_list": "Zréck op d'Lëscht", "pain_subtitle": "Dat sinn d'deeglech Frustratioune vu Letzshop Verkeefer",
"preview": "Virschau", "pain_manual": "Manuell Bestellungsagab",
"revert_to_default": "Op Standard zrécksetzen" "pain_manual_desc": "Bestellunge vu Letzshop an Tabelle kopéieren. All. Eenzelen. Dag.",
}, "pain_inventory": "Lager Chaos",
"messages": { "pain_inventory_desc": "De Stock an Letzshop stëmmt net mat der Realitéit iwwereneen. Iwwerverkeef passéieren.",
"created": "Säit erfollegräich erstallt", "pain_vat": "Falsch TVA Rechnungen",
"updated": "Säit erfollegräich aktualiséiert", "pain_vat_desc": "EU Cliente brauchen déi korrekt TVA. Äre Comptabel beschwéiert sech.",
"deleted": "Säit erfollegräich geläscht", "pain_customers": "Verluer Clienten",
"reverted": "Op Standardsäit zréckgesat", "pain_customers_desc": "Letzshop besëtzt Är Clientsdaten. Dir kënnt se net retargeten oder Loyalitéit opbauen.",
"error_loading": "Feeler beim Lueden vun der Säit", "how_title": "Wéi et funktionéiert",
"error_saving": "Feeler beim Späichere vun der Säit", "how_subtitle": "Vum Chaos zur Kontroll an 4 Schrëtt",
"confirm_delete": "Sidd Dir sécher, datt Dir dës Säit läsche wëllt?" "how_step1": "Letzshop verbannen",
}, "how_step1_desc": "Gitt Är Letzshop API Zougangsdaten an. An 2 Minutte fäerdeg, keng technesch Kenntnisser néideg.",
"filters": { "how_step2": "Bestellunge kommen eran",
"all_pages": "All Säiten", "how_step2_desc": "Bestellunge ginn automatesch synchroniséiert. Confirméiert an Tracking direkt vu Wizamart derbäisetzen.",
"all_platforms": "All Plattformen", "how_step3": "Rechnunge generéieren",
"search_placeholder": "Säite sichen..." "how_step3_desc": "Ee Klick fir konform PDF Rechnunge mat korrekter TVA fir all EU Land ze erstellen.",
"how_step4": "Äert Geschäft ausbauen",
"how_step4_desc": "Exportéiert Clientë fir Marketing. Verfolgt Lagerstänn. Konzentréiert Iech op de Verkaf, net op Tabellen.",
"features_title": "Alles wat e Letzshop Verkeefer brauch",
"features_subtitle": "D'operativ Tools déi Letzshop net bitt",
"cta_final_title": "Prett fir d'Kontroll iwwer Äert Letzshop Geschäft ze iwwerhuelen?",
"cta_final_subtitle": "Schléisst Iech lëtzebuerger Händler un déi opgehalen hunn géint Tabellen ze kämpfen an ugefaang hunn hiert Geschäft auszbauen.",
"cta_final_note": "Keng Kreditkaart néideg. Setup an 5 Minutten. Voll Professional Fonctiounen während der Testperiod."
}
} }
} }

View File

@@ -2,19 +2,24 @@
""" """
CMS module database models. CMS module database models.
This is the canonical location for CMS models. Module models are automatically This is the canonical location for CMS models including:
discovered and registered with SQLAlchemy's Base.metadata at startup. - ContentPage: CMS pages (marketing, vendor default pages)
- MediaFile: Vendor media library
- VendorTheme: Vendor storefront theme configuration
Usage: Usage:
from app.modules.cms.models import ContentPage from app.modules.cms.models import ContentPage, MediaFile, VendorTheme
For media models: For product-media associations:
from models.database.media import MediaFile # Core media file storage from app.modules.catalog.models import ProductMedia
from app.modules.catalog.models import ProductMedia # Product-media associations
""" """
from app.modules.cms.models.content_page import ContentPage from app.modules.cms.models.content_page import ContentPage
from app.modules.cms.models.media import MediaFile
from app.modules.cms.models.vendor_theme import VendorTheme
__all__ = [ __all__ = [
"ContentPage", "ContentPage",
"MediaFile",
"VendorTheme",
] ]

View File

@@ -1,4 +1,4 @@
# models/database/media.py # app/modules/cms/models/media.py
""" """
CORE media file model for vendor media library. CORE media file model for vendor media library.
@@ -121,7 +121,4 @@ class MediaFile(Base, TimestampMixin):
return self.media_type == "document" return self.media_type == "document"
# Re-export ProductMedia from its canonical location for backwards compatibility __all__ = ["MediaFile"]
from app.modules.catalog.models import ProductMedia # noqa: E402, F401
__all__ = ["MediaFile", "ProductMedia"]

View File

@@ -1,4 +1,4 @@
# models/database/vendor_theme.py # app/modules/cms/models/vendor_theme.py
""" """
Vendor Theme Configuration Model Vendor Theme Configuration Model
Allows each vendor to customize their shop's appearance Allows each vendor to customize their shop's appearance
@@ -134,3 +134,6 @@ class VendorTheme(Base, TimestampMixin):
"custom_css": self.custom_css, "custom_css": self.custom_css,
"css_variables": self.css_variables, "css_variables": self.css_variables,
} }
__all__ = ["VendorTheme"]

View File

@@ -23,7 +23,7 @@ from app.modules.cms.schemas import (
SectionUpdateResponse, SectionUpdateResponse,
) )
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
from models.database.user import User from app.modules.tenancy.models import User
admin_content_pages_router = APIRouter(prefix="/content-pages") admin_content_pages_router = APIRouter(prefix="/content-pages")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, File, Form, UploadFile
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
from app.modules.core.services.image_service import image_service from app.modules.core.services.image_service import image_service
from models.schema.auth import UserContext from models.schema.auth import UserContext
from models.schema.image import ( from app.modules.cms.schemas.image import (
ImageDeleteResponse, ImageDeleteResponse,
ImageStorageStats, ImageStorageStats,
ImageUploadResponse, ImageUploadResponse,

View File

@@ -14,7 +14,7 @@ from app.api.deps import get_current_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.modules.cms.services.media_service import media_service from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext from models.schema.auth import UserContext
from models.schema.media import ( from app.modules.cms.schemas.media import (
MediaDetailResponse, MediaDetailResponse,
MediaItemResponse, MediaItemResponse,
MediaListResponse, MediaListResponse,

View File

@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, get_db from app.api.deps import get_current_admin_api, get_db
from app.modules.cms.services.vendor_theme_service import vendor_theme_service from app.modules.cms.services.vendor_theme_service import vendor_theme_service
from models.schema.auth import UserContext from models.schema.auth import UserContext
from models.schema.vendor_theme import ( from app.modules.cms.schemas.vendor_theme import (
ThemeDeleteResponse, ThemeDeleteResponse,
ThemePresetListResponse, ThemePresetListResponse,
ThemePresetResponse, ThemePresetResponse,

View File

@@ -26,7 +26,7 @@ from app.modules.cms.schemas import (
) )
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
from app.modules.tenancy.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service from app.modules.tenancy.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
from models.database.user import User from app.modules.tenancy.models import User
vendor_service = VendorService() vendor_service = VendorService()

View File

@@ -16,7 +16,7 @@ from app.core.database import get_db
from app.modules.cms.exceptions import MediaOptimizationException from app.modules.cms.exceptions import MediaOptimizationException
from app.modules.cms.services.media_service import media_service from app.modules.cms.services.media_service import media_service
from models.schema.auth import UserContext from models.schema.auth import UserContext
from models.schema.media import ( from app.modules.cms.schemas.media import (
MediaDetailResponse, MediaDetailResponse,
MediaItemResponse, MediaItemResponse,
MediaListResponse, MediaListResponse,

View File

@@ -11,8 +11,8 @@ 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.templates_config import templates from app.templates_config import templates
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -15,8 +15,8 @@ from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service from app.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service
from app.templates_config import templates from app.templates_config import templates
from models.database.user import User from app.modules.tenancy.models import User
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -35,6 +35,44 @@ from app.modules.cms.schemas.homepage_sections import (
HomepageSectionsResponse, HomepageSectionsResponse,
) )
# Media schemas
from app.modules.cms.schemas.media import (
FailedFileInfo,
MediaDetailResponse,
MediaItemResponse,
MediaListResponse,
MediaMetadataUpdate,
MediaUploadResponse,
MediaUsageResponse,
MessageResponse,
MultipleUploadResponse,
OptimizationResultResponse,
ProductUsageInfo,
UploadedFileInfo,
)
# Image schemas
from app.modules.cms.schemas.image import (
ImageDeleteResponse,
ImageStorageStats,
ImageUploadResponse,
ImageUrls,
)
# Theme schemas
from app.modules.cms.schemas.vendor_theme import (
ThemeDeleteResponse,
ThemePresetListResponse,
ThemePresetPreview,
ThemePresetResponse,
VendorThemeBranding,
VendorThemeColors,
VendorThemeFonts,
VendorThemeLayout,
VendorThemeResponse,
VendorThemeUpdate,
)
__all__ = [ __all__ = [
# Content Page - Admin # Content Page - Admin
"ContentPageCreate", "ContentPageCreate",
@@ -60,4 +98,33 @@ __all__ = [
"HomepageSections", "HomepageSections",
"SectionUpdateRequest", "SectionUpdateRequest",
"HomepageSectionsResponse", "HomepageSectionsResponse",
# Media
"FailedFileInfo",
"MediaDetailResponse",
"MediaItemResponse",
"MediaListResponse",
"MediaMetadataUpdate",
"MediaUploadResponse",
"MediaUsageResponse",
"MessageResponse",
"MultipleUploadResponse",
"OptimizationResultResponse",
"ProductUsageInfo",
"UploadedFileInfo",
# Image
"ImageDeleteResponse",
"ImageStorageStats",
"ImageUploadResponse",
"ImageUrls",
# Theme
"ThemeDeleteResponse",
"ThemePresetListResponse",
"ThemePresetPreview",
"ThemePresetResponse",
"VendorThemeBranding",
"VendorThemeColors",
"VendorThemeFonts",
"VendorThemeLayout",
"VendorThemeResponse",
"VendorThemeUpdate",
] ]

View File

@@ -1,4 +1,4 @@
# models/schema/image.py # app/modules/cms/schemas/image.py
""" """
Pydantic schemas for image operations. Pydantic schemas for image operations.
""" """

View File

@@ -1,4 +1,4 @@
# models/schema/media.py # app/modules/cms/schemas/media.py
""" """
Media/file management Pydantic schemas for API validation and responses. Media/file management Pydantic schemas for API validation and responses.

View File

@@ -1,4 +1,4 @@
# models/schema/vendor_theme.py # app/modules/cms/schemas/vendor_theme.py
""" """
Pydantic schemas for vendor theme operations. Pydantic schemas for vendor theme operations.
""" """

View File

@@ -27,7 +27,7 @@ from app.modules.cms.exceptions import (
UnsupportedMediaTypeException, UnsupportedMediaTypeException,
MediaFileTooLargeException, MediaFileTooLargeException,
) )
from models.database.media import MediaFile from app.modules.cms.models import MediaFile
from app.modules.catalog.models import ProductMedia from app.modules.catalog.models import ProductMedia
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -24,14 +24,13 @@ from app.exceptions import (
ValidationException, ValidationException,
ExternalServiceException, ExternalServiceException,
) )
from models.database import ( from app.modules.tenancy.models import Vendor
Vendor, from app.modules.messaging.models import (
VendorEmailSettings, VendorEmailSettings,
EmailProvider, EmailProvider,
PREMIUM_EMAIL_PROVIDERS, PREMIUM_EMAIL_PROVIDERS,
VendorSubscription,
TierCode,
) )
from app.modules.billing.models import VendorSubscription, TierCode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -26,9 +26,9 @@ from app.modules.cms.exceptions import (
ThemeValidationException, ThemeValidationException,
VendorThemeNotFoundException, VendorThemeNotFoundException,
) )
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
from models.database.vendor_theme import VendorTheme from app.modules.cms.models import VendorTheme
from models.schema.vendor_theme import ThemePresetPreview, VendorThemeUpdate from app.modules.cms.schemas.vendor_theme import ThemePresetPreview, VendorThemeUpdate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -28,6 +28,9 @@ function contentPagesManager() {
// Initialize // Initialize
async init() { async init() {
// Load i18n translations
await I18n.loadModule('cms');
contentPagesLog.info('=== CONTENT PAGES MANAGER INITIALIZING ==='); contentPagesLog.info('=== CONTENT PAGES MANAGER INITIALIZING ===');
// Prevent multiple initializations // Prevent multiple initializations
@@ -235,7 +238,7 @@ function contentPagesManager() {
} catch (err) { } catch (err) {
contentPagesLog.error('Error deleting page:', err); contentPagesLog.error('Error deleting page:', err);
Utils.showToast(`Failed to delete page: ${err.message}`, 'error'); Utils.showToast(I18n.t('cms.messages.failed_to_delete_page', { error: err.message }), 'error');
} }
}, },

View File

@@ -20,24 +20,24 @@
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg> </svg>
{{ _("platform.hero.badge", trial_days=trial_days) }} {{ _("cms.platform.hero.badge", trial_days=trial_days) }}
</div> </div>
{# Headline #} {# Headline #}
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6"> <h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6">
{{ _("platform.hero.title") }} {{ _("cms.platform.hero.title") }}
</h1> </h1>
{# Subheadline #} {# Subheadline #}
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10"> <p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10">
{{ _("platform.hero.subtitle") }} {{ _("cms.platform.hero.subtitle") }}
</p> </p>
{# CTA Buttons #} {# CTA Buttons #}
<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="/signup" <a href="/signup"
class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105"> class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105">
{{ _("platform.hero.cta_trial") }} {{ _("cms.platform.hero.cta_trial") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg> </svg>
@@ -47,7 +47,7 @@
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg> </svg>
{{ _("platform.hero.cta_find_shop") }} {{ _("cms.platform.hero.cta_find_shop") }}
</a> </a>
</div> </div>
</div> </div>
@@ -68,19 +68,19 @@
{# Section Header #} {# Section Header #}
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4"> <h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.pricing.title") }} {{ _("cms.platform.pricing.title") }}
</h2> </h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> <p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("platform.pricing.subtitle", trial_days=trial_days) }} {{ _("cms.platform.pricing.subtitle", trial_days=trial_days) }}
</p> </p>
{# Billing Toggle #} {# Billing Toggle #}
<div class="flex justify-center mt-8"> <div class="flex justify-center mt-8">
{{ toggle_switch( {{ toggle_switch(
model='annual', model='annual',
left_label=_("platform.pricing.monthly"), left_label=_("cms.platform.pricing.monthly"),
right_label=_("platform.pricing.annual"), right_label=_("cms.platform.pricing.annual"),
right_badge=_("platform.pricing.save_months") right_badge=_("cms.platform.pricing.save_months")
) }} ) }}
</div> </div>
</div> </div>
@@ -95,7 +95,7 @@
{% if tier.is_popular %} {% if tier.is_popular %}
<div class="absolute -top-3 left-1/2 -translate-x-1/2"> <div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full"> <span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
{{ _("platform.pricing.most_popular") }} {{ _("cms.platform.pricing.most_popular") }}
</span> </span>
</div> </div>
{% endif %} {% endif %}
@@ -108,19 +108,19 @@
<template x-if="!annual"> <template x-if="!annual">
<div> <div>
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€</span> <span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("platform.pricing.per_month") }}</span> <span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
</div> </div>
</template> </template>
<template x-if="annual"> <template x-if="annual">
<div> <div>
{% if tier.price_annual %} {% if tier.price_annual %}
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}€</span> <span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("platform.pricing.per_month") }}</span> <span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
<div class="text-sm text-gray-500 dark:text-gray-400"> <div class="text-sm text-gray-500 dark:text-gray-400">
{{ tier.price_annual|int }}€ {{ _("platform.pricing.per_year") }} {{ tier.price_annual|int }}€ {{ _("cms.platform.pricing.per_year") }}
</div> </div>
{% else %} {% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("platform.pricing.custom") }}</span> <span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("cms.platform.pricing.custom") }}</span>
{% endif %} {% endif %}
</div> </div>
</template> </template>
@@ -133,28 +133,28 @@
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{% if tier.orders_per_month %}{{ _("platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("platform.pricing.unlimited_orders") }}{% endif %} {% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
</li> </li>
{# Products #} {# Products #}
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{% if tier.products_limit %}{{ _("platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("platform.pricing.unlimited_products") }}{% endif %} {% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
</li> </li>
{# Team Members #} {# Team Members #}
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{% if tier.team_members %}{{ _("platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("platform.pricing.unlimited_team") }}{% endif %} {% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
</li> </li>
{# Letzshop Sync - always included #} {# Letzshop Sync - always included #}
<li class="flex items-center text-gray-700 dark:text-gray-300"> <li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg> </svg>
{{ _("platform.pricing.letzshop_sync") }} {{ _("cms.platform.pricing.letzshop_sync") }}
</li> </li>
{# EU VAT Invoicing #} {# EU VAT Invoicing #}
<li class="flex items-center {% if 'invoice_eu_vat' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}"> <li class="flex items-center {% if 'invoice_eu_vat' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
@@ -167,7 +167,7 @@
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
{{ _("platform.pricing.eu_vat_invoicing") }} {{ _("cms.platform.pricing.eu_vat_invoicing") }}
</li> </li>
{# Analytics Dashboard #} {# Analytics Dashboard #}
<li class="flex items-center {% if 'analytics_dashboard' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}"> <li class="flex items-center {% if 'analytics_dashboard' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
@@ -180,7 +180,7 @@
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
{{ _("platform.pricing.analytics_dashboard") }} {{ _("cms.platform.pricing.analytics_dashboard") }}
</li> </li>
{# API Access #} {# API Access #}
<li class="flex items-center {% if 'api_access' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}"> <li class="flex items-center {% if 'api_access' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
@@ -193,7 +193,7 @@
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
{{ _("platform.pricing.api_access") }} {{ _("cms.platform.pricing.api_access") }}
</li> </li>
{# Multi-channel Integration - Enterprise only #} {# Multi-channel Integration - Enterprise only #}
<li class="flex items-center {% if tier.is_enterprise %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}"> <li class="flex items-center {% if tier.is_enterprise %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
@@ -206,7 +206,7 @@
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg> </svg>
{% endif %} {% endif %}
{{ _("platform.pricing.multi_channel") }} {{ _("cms.platform.pricing.multi_channel") }}
</li> </li>
</ul> </ul>
@@ -214,14 +214,14 @@
{% if tier.is_enterprise %} {% if tier.is_enterprise %}
<a href="mailto:sales@wizamart.com?subject=Enterprise%20Plan%20Inquiry" <a href="mailto:sales@wizamart.com?subject=Enterprise%20Plan%20Inquiry"
class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"> class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _("platform.pricing.contact_sales") }} {{ _("cms.platform.pricing.contact_sales") }}
</a> </a>
{% else %} {% else %}
<a href="/signup?tier={{ tier.code }}" <a href="/signup?tier={{ tier.code }}"
:href="'/signup?tier={{ tier.code }}&annual=' + annual" :href="'/signup?tier={{ tier.code }}&annual=' + annual"
class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}"> {% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}">
{{ _("platform.pricing.start_trial") }} {{ _("cms.platform.pricing.start_trial") }}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -238,10 +238,10 @@
{# Section Header #} {# Section Header #}
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4"> <h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.addons.title") }} {{ _("cms.platform.addons.title") }}
</h2> </h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> <p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("platform.addons.subtitle") }} {{ _("cms.platform.addons.subtitle") }}
</p> </p>
</div> </div>
@@ -300,10 +300,10 @@
{# Section Header #} {# Section Header #}
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4"> <h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("platform.find_shop.title") }} {{ _("cms.platform.find_shop.title") }}
</h2> </h2>
<p class="text-lg text-gray-600 dark:text-gray-400"> <p class="text-lg text-gray-600 dark:text-gray-400">
{{ _("platform.find_shop.subtitle") }} {{ _("cms.platform.find_shop.subtitle") }}
</p> </p>
</div> </div>
@@ -313,7 +313,7 @@
<input <input
type="text" type="text"
x-model="shopUrl" x-model="shopUrl"
placeholder="{{ _('platform.find_shop.placeholder') }}" placeholder="{{ _('cms.platform.find_shop.placeholder') }}"
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent" class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/> />
<button <button
@@ -326,7 +326,7 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg> </svg>
</template> </template>
{{ _("platform.find_shop.button") }} {{ _("cms.platform.find_shop.button") }}
</button> </button>
</div> </div>
@@ -342,12 +342,12 @@
<template x-if="!vendorResult.vendor.is_claimed"> <template x-if="!vendorResult.vendor.is_claimed">
<a :href="'/signup?letzshop=' + vendorResult.vendor.slug" <a :href="'/signup?letzshop=' + vendorResult.vendor.slug"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"> class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors">
{{ _("platform.find_shop.claim_shop") }} {{ _("cms.platform.find_shop.claim_shop") }}
</a> </a>
</template> </template>
<template x-if="vendorResult.vendor.is_claimed"> <template x-if="vendorResult.vendor.is_claimed">
<span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg"> <span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg">
{{ _("platform.find_shop.already_claimed") }} {{ _("cms.platform.find_shop.already_claimed") }}
</span> </span>
</template> </template>
</div> </div>
@@ -362,7 +362,7 @@
{# Help Text #} {# Help Text #}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center"> <p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ _("platform.find_shop.no_account") }} <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("platform.find_shop.signup_letzshop") }}</a>{{ _("platform.find_shop.then_connect") }} {{ _("cms.platform.find_shop.no_account") }} <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("cms.platform.find_shop.signup_letzshop") }}</a>{{ _("cms.platform.find_shop.then_connect") }}
</p> </p>
</div> </div>
</div> </div>
@@ -374,14 +374,14 @@
<section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600"> <section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6"> <h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
{{ _("platform.cta.title") }} {{ _("cms.platform.cta.title") }}
</h2> </h2>
<p class="text-xl text-indigo-100 mb-10"> <p class="text-xl text-indigo-100 mb-10">
{{ _("platform.cta.subtitle", trial_days=trial_days) }} {{ _("cms.platform.cta.subtitle", trial_days=trial_days) }}
</p> </p>
<a href="/signup" <a href="/signup"
class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105"> class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105">
{{ _("platform.cta.button") }} {{ _("cms.platform.cta.button") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg> </svg>

View File

@@ -6,8 +6,8 @@ Dashboard, settings, and profile management.
Required for basic operation - cannot be disabled. Required for basic operation - cannot be disabled.
""" """
from app.modules.base import ModuleDefinition from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
core_module = ModuleDefinition( core_module = ModuleDefinition(
code="core", code="core",
@@ -21,6 +21,7 @@ core_module = ModuleDefinition(
"settings", "settings",
"profile", "profile",
], ],
# Legacy menu_items (IDs only)
menu_items={ menu_items={
FrontendType.ADMIN: [ FrontendType.ADMIN: [
"dashboard", "dashboard",
@@ -35,6 +36,95 @@ core_module = ModuleDefinition(
"email-templates", "email-templates",
], ],
}, },
# New module-driven menu definitions
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="main",
label_key=None, # No header for main section
icon=None,
order=0,
is_collapsible=False,
items=[
MenuItemDefinition(
id="dashboard",
label_key="core.menu.dashboard",
icon="home",
route="/admin/dashboard",
order=10,
is_mandatory=True,
),
],
),
MenuSectionDefinition(
id="settings",
label_key="core.menu.platform_settings",
icon="cog",
order=900,
items=[
MenuItemDefinition(
id="settings",
label_key="core.menu.general",
icon="cog",
route="/admin/settings",
order=10,
is_mandatory=True,
),
MenuItemDefinition(
id="my-menu",
label_key="core.menu.my_menu",
icon="view-grid",
route="/admin/my-menu",
order=30,
is_mandatory=True,
is_super_admin_only=True,
),
],
),
],
FrontendType.VENDOR: [
MenuSectionDefinition(
id="main",
label_key=None,
icon=None,
order=0,
is_collapsible=False,
items=[
MenuItemDefinition(
id="dashboard",
label_key="core.menu.dashboard",
icon="home",
route="/vendor/{vendor_code}/dashboard",
order=10,
is_mandatory=True,
),
],
),
MenuSectionDefinition(
id="account",
label_key="core.menu.account_settings",
icon="user",
order=900,
items=[
MenuItemDefinition(
id="profile",
label_key="core.menu.profile",
icon="user",
route="/vendor/{vendor_code}/profile",
order=10,
),
MenuItemDefinition(
id="settings",
label_key="core.menu.settings",
icon="cog",
route="/vendor/{vendor_code}/settings",
order=20,
is_mandatory=True,
),
],
),
],
},
) )
__all__ = ["core_module"] __all__ = ["core_module"]

View File

@@ -0,0 +1,34 @@
# app/modules/core/exceptions.py
"""Core module exceptions.
Exceptions for core platform functionality including:
- Menu configuration
- Dashboard operations
- Settings management
"""
from app.exceptions import WizamartException
class CoreException(WizamartException):
"""Base exception for core module."""
pass
class MenuConfigurationError(CoreException):
"""Error in menu configuration."""
pass
class SettingsError(CoreException):
"""Error in platform settings."""
pass
class DashboardError(CoreException):
"""Error in dashboard operations."""
pass

View File

@@ -0,0 +1,71 @@
{
"dashboard": {
"title": "Dashboard",
"welcome": "Willkommen zurück",
"overview": "Übersicht",
"quick_stats": "Schnellstatistiken",
"recent_activity": "Letzte Aktivitäten",
"total_products": "Produkte gesamt",
"total_orders": "Bestellungen gesamt",
"total_customers": "Kunden gesamt",
"total_revenue": "Gesamtumsatz",
"active_products": "Aktive Produkte",
"pending_orders": "Ausstehende Bestellungen",
"new_customers": "Neue Kunden",
"today": "Heute",
"this_week": "Diese Woche",
"this_month": "Dieser Monat",
"this_year": "Dieses Jahr",
"error_loading": "Fehler beim Laden des Dashboards",
"no_data": "Keine Daten verfügbar"
},
"settings": {
"title": "Einstellungen",
"general": "Allgemein",
"store": "Shop",
"store_name": "Shop-Name",
"store_description": "Shop-Beschreibung",
"contact_email": "Kontakt-E-Mail",
"contact_phone": "Kontakttelefon",
"business_address": "Geschäftsadresse",
"tax_number": "Steuernummer",
"currency": "Währung",
"timezone": "Zeitzone",
"language": "Sprache",
"language_settings": "Spracheinstellungen",
"default_language": "Standardsprache",
"dashboard_language": "Dashboard-Sprache",
"storefront_language": "Shop-Sprache",
"enabled_languages": "Aktivierte Sprachen",
"notifications": "Benachrichtigungen",
"email_notifications": "E-Mail-Benachrichtigungen",
"integrations": "Integrationen",
"api_keys": "API-Schlüssel",
"webhooks": "Webhooks",
"save_settings": "Einstellungen speichern",
"settings_saved": "Einstellungen erfolgreich gespeichert"
},
"profile": {
"title": "Profil",
"my_profile": "Mein Profil",
"edit_profile": "Profil bearbeiten",
"personal_info": "Persönliche Informationen",
"first_name": "Vorname",
"last_name": "Nachname",
"email": "E-Mail",
"phone": "Telefon",
"avatar": "Profilbild",
"change_avatar": "Profilbild ändern",
"security": "Sicherheit",
"two_factor": "Zwei-Faktor-Authentifizierung",
"sessions": "Aktive Sitzungen",
"preferences": "Präferenzen",
"language_preference": "Sprachpräferenz",
"save_profile": "Profil speichern",
"profile_updated": "Profil erfolgreich aktualisiert"
},
"messages": {
"failed_to_load_dashboard_data": "Failed to load dashboard data",
"dashboard_refreshed": "Dashboard refreshed"
}
}

View File

@@ -0,0 +1,71 @@
{
"dashboard": {
"title": "Tableau de bord",
"welcome": "Bienvenue",
"overview": "Vue d'ensemble",
"quick_stats": "Statistiques rapides",
"recent_activity": "Activité récente",
"total_products": "Total des produits",
"total_orders": "Total des commandes",
"total_customers": "Total des clients",
"total_revenue": "Chiffre d'affaires total",
"active_products": "Produits actifs",
"pending_orders": "Commandes en attente",
"new_customers": "Nouveaux clients",
"today": "Aujourd'hui",
"this_week": "Cette semaine",
"this_month": "Ce mois",
"this_year": "Cette année",
"error_loading": "Erreur lors du chargement du tableau de bord",
"no_data": "Aucune donnée disponible"
},
"settings": {
"title": "Paramètres",
"general": "Général",
"store": "Boutique",
"store_name": "Nom de la boutique",
"store_description": "Description de la boutique",
"contact_email": "E-mail de contact",
"contact_phone": "Téléphone de contact",
"business_address": "Adresse professionnelle",
"tax_number": "Numéro de TVA",
"currency": "Devise",
"timezone": "Fuseau horaire",
"language": "Langue",
"language_settings": "Paramètres de langue",
"default_language": "Langue par défaut",
"dashboard_language": "Langue du tableau de bord",
"storefront_language": "Langue de la boutique",
"enabled_languages": "Langues activées",
"notifications": "Notifications",
"email_notifications": "Notifications par e-mail",
"integrations": "Intégrations",
"api_keys": "Clés API",
"webhooks": "Webhooks",
"save_settings": "Enregistrer les paramètres",
"settings_saved": "Paramètres enregistrés avec succès"
},
"profile": {
"title": "Profil",
"my_profile": "Mon profil",
"edit_profile": "Modifier le profil",
"personal_info": "Informations personnelles",
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"avatar": "Avatar",
"change_avatar": "Changer l'avatar",
"security": "Sécurité",
"two_factor": "Authentification à deux facteurs",
"sessions": "Sessions actives",
"preferences": "Préférences",
"language_preference": "Préférence de langue",
"save_profile": "Enregistrer le profil",
"profile_updated": "Profil mis à jour avec succès"
},
"messages": {
"failed_to_load_dashboard_data": "Failed to load dashboard data",
"dashboard_refreshed": "Dashboard refreshed"
}
}

View File

@@ -0,0 +1,71 @@
{
"dashboard": {
"title": "Dashboard",
"welcome": "Wëllkomm zréck",
"overview": "Iwwersiicht",
"quick_stats": "Séier Statistiken",
"recent_activity": "Rezent Aktivitéit",
"total_products": "Produkter insgesamt",
"total_orders": "Bestellungen insgesamt",
"total_customers": "Clienten insgesamt",
"total_revenue": "Ëmsaz insgesamt",
"active_products": "Aktiv Produkter",
"pending_orders": "Aussteesend Bestellungen",
"new_customers": "Nei Clienten",
"today": "Haut",
"this_week": "Dës Woch",
"this_month": "Dëse Mount",
"this_year": "Dëst Joer",
"error_loading": "Feeler beim Lueden vum Dashboard",
"no_data": "Keng Donnéeën disponibel"
},
"settings": {
"title": "Astellungen",
"general": "Allgemeng",
"store": "Buttek",
"store_name": "Butteknumm",
"store_description": "Buttekbeschreiwung",
"contact_email": "Kontakt E-Mail",
"contact_phone": "Kontakt Telefon",
"business_address": "Geschäftsadress",
"tax_number": "Steiernummer",
"currency": "Wärung",
"timezone": "Zäitzon",
"language": "Sprooch",
"language_settings": "Sproochastellungen",
"default_language": "Standard Sprooch",
"dashboard_language": "Dashboard Sprooch",
"storefront_language": "Buttek Sprooch",
"enabled_languages": "Aktivéiert Sproochen",
"notifications": "Notifikatiounen",
"email_notifications": "E-Mail Notifikatiounen",
"integrations": "Integratiounen",
"api_keys": "API Schlësselen",
"webhooks": "Webhooks",
"save_settings": "Astellunge späicheren",
"settings_saved": "Astellungen erfollegräich gespäichert"
},
"profile": {
"title": "Profil",
"my_profile": "Mäi Profil",
"edit_profile": "Profil änneren",
"personal_info": "Perséinlech Informatiounen",
"first_name": "Virnumm",
"last_name": "Nonumm",
"email": "E-Mail",
"phone": "Telefon",
"avatar": "Avatar",
"change_avatar": "Avatar änneren",
"security": "Sécherheet",
"two_factor": "Zwee-Faktor Authentifikatioun",
"sessions": "Aktiv Sessiounen",
"preferences": "Astellungen",
"language_preference": "Sproochpräferenz",
"save_profile": "Profil späicheren",
"profile_updated": "Profil erfollegräich aktualiséiert"
},
"messages": {
"failed_to_load_dashboard_data": "Failed to load dashboard data",
"dashboard_refreshed": "Dashboard refreshed"
}
}

View File

@@ -0,0 +1,18 @@
# app/modules/core/models/__init__.py
"""
Core module database models.
This is the canonical location for core module models.
"""
from app.modules.core.models.admin_menu_config import (
AdminMenuConfig,
FrontendType,
MANDATORY_MENU_ITEMS,
)
__all__ = [
"AdminMenuConfig",
"FrontendType",
"MANDATORY_MENU_ITEMS",
]

View File

@@ -1,4 +1,4 @@
# models/database/admin_menu_config.py # app/modules/core/models/admin_menu_config.py
""" """
Menu visibility configuration for admin and vendor frontends. Menu visibility configuration for admin and vendor frontends.
@@ -36,9 +36,6 @@ from app.core.database import Base
from models.database.base import TimestampMixin from models.database.base import TimestampMixin
# Import FrontendType and MANDATORY_MENU_ITEMS from the central location # Import FrontendType and MANDATORY_MENU_ITEMS from the central location
# and re-export for backward compatibility with existing imports.
# These were moved to app.modules.enums to break a circular import:
# app.modules.base -> models.database -> model discovery -> module definitions -> app.modules.base
from app.modules.enums import FrontendType, MANDATORY_MENU_ITEMS from app.modules.enums import FrontendType, MANDATORY_MENU_ITEMS
@@ -221,3 +218,6 @@ class AdminMenuConfig(Base, TimestampMixin):
f"menu_item_id='{self.menu_item_id}', " f"menu_item_id='{self.menu_item_id}', "
f"is_visible={self.is_visible})>" f"is_visible={self.is_visible})>"
) )
__all__ = ["AdminMenuConfig", "FrontendType", "MANDATORY_MENU_ITEMS"]

View File

@@ -5,15 +5,18 @@ Core module admin API routes.
Aggregates all admin core routes: Aggregates all admin core routes:
- /dashboard/* - Admin dashboard and statistics - /dashboard/* - Admin dashboard and statistics
- /settings/* - Platform settings management - /settings/* - Platform settings management
- /menu-config/* - Menu visibility configuration
""" """
from fastapi import APIRouter from fastapi import APIRouter
from .admin_dashboard import admin_dashboard_router from .admin_dashboard import admin_dashboard_router
from .admin_settings import admin_settings_router from .admin_settings import admin_settings_router
from .admin_menu_config import router as admin_menu_config_router
admin_router = APIRouter() admin_router = APIRouter()
# Aggregate all core admin routes # Aggregate all core admin routes
admin_router.include_router(admin_dashboard_router, tags=["admin-dashboard"]) admin_router.include_router(admin_dashboard_router, tags=["admin-dashboard"])
admin_router.include_router(admin_settings_router, tags=["admin-settings"]) admin_router.include_router(admin_settings_router, tags=["admin-settings"])
admin_router.include_router(admin_menu_config_router, tags=["admin-menu-config"])

View File

@@ -1,4 +1,4 @@
# app/api/v1/admin/menu_config.py # app/modules/core/routes/api/admin_menu_config.py
""" """
Admin API endpoints for Platform Menu Configuration. Admin API endpoints for Platform Menu Configuration.
@@ -29,7 +29,7 @@ from app.api.deps import (
) )
from app.modules.core.services.menu_service import MenuItemConfig, menu_service from app.modules.core.services.menu_service import MenuItemConfig, menu_service
from app.modules.tenancy.services.platform_service import platform_service from app.modules.tenancy.services.platform_service import platform_service
from models.database.admin_menu_config import FrontendType # noqa: API-007 - Enum for type safety from app.modules.enums import FrontendType # noqa: API-007 - Enum for type safety
from models.schema.auth import UserContext from models.schema.auth import UserContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -23,7 +23,7 @@ from app.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.monitoring.services.admin_audit_service import admin_audit_service from app.modules.monitoring.services.admin_audit_service import admin_audit_service
from app.modules.core.services.admin_settings_service import admin_settings_service from app.modules.core.services.admin_settings_service import admin_settings_service
from models.schema.auth import UserContext from models.schema.auth import UserContext
from models.schema.admin import ( from app.modules.tenancy.schemas.admin import (
AdminSettingCreate, AdminSettingCreate,
AdminSettingDefaultResponse, AdminSettingDefaultResponse,
AdminSettingListResponse, AdminSettingListResponse,
@@ -528,7 +528,7 @@ def update_email_settings(
Settings are stored in the database and override .env values. Settings are stored in the database and override .env values.
Only non-null values are updated. Only non-null values are updated.
""" """
from models.schema.admin import AdminSettingCreate from app.modules.tenancy.schemas.admin import AdminSettingCreate
updated_keys = [] updated_keys = []

View File

@@ -16,8 +16,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_optional, get_db, require_menu_access from app.api.deps import get_current_admin_optional, get_db, require_menu_access
from app.modules.core.utils.page_context import get_admin_context from app.modules.core.utils.page_context import get_admin_context
from app.templates_config import templates from app.templates_config import templates
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_from_cookie_or_header, get_db from app.api.deps import get_current_vendor_from_cookie_or_header, get_db
from app.modules.core.utils.page_context import get_vendor_context from app.modules.core.utils.page_context import get_vendor_context
from app.templates_config import templates from app.templates_config import templates
from models.database.user import User from app.modules.tenancy.models import User
router = APIRouter() router = APIRouter()

View File

View File

@@ -18,6 +18,12 @@ from app.modules.core.services.admin_settings_service import (
from app.modules.core.services.auth_service import AuthService, auth_service from app.modules.core.services.auth_service import AuthService, auth_service
from app.modules.core.services.image_service import ImageService, image_service from app.modules.core.services.image_service import ImageService, image_service
from app.modules.core.services.menu_service import MenuItemConfig, MenuService, menu_service from app.modules.core.services.menu_service import MenuItemConfig, MenuService, menu_service
from app.modules.core.services.menu_discovery_service import (
DiscoveredMenuItem,
DiscoveredMenuSection,
MenuDiscoveryService,
menu_discovery_service,
)
from app.modules.core.services.platform_settings_service import ( from app.modules.core.services.platform_settings_service import (
PlatformSettingsService, PlatformSettingsService,
platform_settings_service, platform_settings_service,
@@ -34,10 +40,15 @@ __all__ = [
# Auth # Auth
"AuthService", "AuthService",
"auth_service", "auth_service",
# Menu # Menu (legacy)
"MenuService", "MenuService",
"MenuItemConfig", "MenuItemConfig",
"menu_service", "menu_service",
# Menu Discovery (module-driven)
"MenuDiscoveryService",
"DiscoveredMenuItem",
"DiscoveredMenuSection",
"menu_discovery_service",
# Image # Image
"ImageService", "ImageService",
"image_service", "image_service",

View File

@@ -21,8 +21,8 @@ from app.exceptions import (
ValidationException, ValidationException,
) )
from app.modules.tenancy.exceptions import AdminOperationException from app.modules.tenancy.exceptions import AdminOperationException
from models.database.admin import AdminSetting from app.modules.tenancy.models import AdminSetting
from models.schema.admin import ( from app.modules.tenancy.schemas.admin import (
AdminSettingCreate, AdminSettingCreate,
AdminSettingResponse, AdminSettingResponse,
AdminSettingUpdate, AdminSettingUpdate,

View File

@@ -19,8 +19,8 @@ from sqlalchemy.orm import Session
from app.modules.tenancy.exceptions import InvalidCredentialsException, UserNotActiveException from app.modules.tenancy.exceptions import InvalidCredentialsException, UserNotActiveException
from middleware.auth import AuthManager from middleware.auth import AuthManager
from models.database.user import User from app.modules.tenancy.models import User
from models.database.vendor import Vendor, VendorUser from app.modules.tenancy.models import Vendor, VendorUser
from models.schema.auth import UserLogin from models.schema.auth import UserLogin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,446 @@
# app/modules/core/services/menu_discovery_service.py
"""
Menu Discovery Service - Discovers and aggregates menu items from all modules.
This service implements the module-driven menu system where each module
defines its own menu items through MenuSectionDefinition and MenuItemDefinition
in its definition.py file.
Key Features:
- Discovers menu definitions from all loaded modules
- Filters by module enablement (disabled modules = hidden menus)
- Respects user/platform visibility preferences (AdminMenuConfig)
- Supports permission-based filtering
- Enforces mandatory item visibility
Usage:
from app.modules.core.services.menu_discovery_service import menu_discovery_service
# Get complete menu for admin frontend
menu = menu_discovery_service.get_menu_for_frontend(
db,
FrontendType.ADMIN,
platform_id=1,
user=current_user
)
# Get flat list of all menu items for configuration UI
items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN)
"""
import logging
from copy import deepcopy
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from app.modules.base import MenuItemDefinition, MenuSectionDefinition
from app.modules.enums import FrontendType
from app.modules.service import module_service
logger = logging.getLogger(__name__)
@dataclass
class DiscoveredMenuItem:
"""
A menu item discovered from a module, enriched with runtime info.
Extends MenuItemDefinition with runtime context like visibility status,
module enablement, and resolved route.
"""
id: str
label_key: str
icon: str
route: str
order: int
is_mandatory: bool
requires_permission: str | None
badge_source: str | None
is_super_admin_only: bool
# Runtime enrichment
module_code: str
section_id: str
section_label_key: str | None
section_order: int
is_visible: bool = True
is_module_enabled: bool = True
@dataclass
class DiscoveredMenuSection:
"""
A menu section discovered from modules, with aggregated items.
Multiple modules may contribute items to the same section.
"""
id: str
label_key: str | None
icon: str | None
order: int
is_super_admin_only: bool
is_collapsible: bool
items: list[DiscoveredMenuItem] = field(default_factory=list)
class MenuDiscoveryService:
"""
Service to discover and aggregate menu items from all enabled modules.
This service:
1. Collects menu definitions from all module definition.py files
2. Filters by module enablement for the platform
3. Applies user/platform visibility preferences
4. Supports permission-based filtering
5. Returns sorted, renderable menu structures
"""
def discover_all_menus(self) -> dict[FrontendType, list[MenuSectionDefinition]]:
"""
Discover all menu definitions from all loaded modules.
Returns:
Dict mapping FrontendType to list of MenuSectionDefinition
from all modules (not filtered by enablement).
"""
from app.modules.registry import MODULES
all_menus: dict[FrontendType, list[MenuSectionDefinition]] = {
ft: [] for ft in FrontendType
}
for module_code, module_def in MODULES.items():
for frontend_type, sections in module_def.menus.items():
all_menus[frontend_type].extend(deepcopy(sections))
return all_menus
def get_menu_sections_for_frontend(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get aggregated menu sections for a frontend type.
Filters by module enablement if platform_id is provided.
Does NOT apply user visibility preferences (use get_menu_for_frontend for that).
Args:
db: Database session
frontend_type: Frontend type to get menus for
platform_id: Platform ID for module enablement filtering
Returns:
List of DiscoveredMenuSection sorted by order
"""
from app.modules.registry import MODULES
# Track sections by ID for aggregation
sections_map: dict[str, DiscoveredMenuSection] = {}
for module_code, module_def in MODULES.items():
# Check if module is enabled for this platform
is_module_enabled = True
if platform_id:
is_module_enabled = module_service.is_module_enabled(
db, platform_id, module_code
)
# Get menu sections for this frontend type
module_sections = module_def.menus.get(frontend_type, [])
for section in module_sections:
# Get or create section entry
if section.id not in sections_map:
sections_map[section.id] = DiscoveredMenuSection(
id=section.id,
label_key=section.label_key,
icon=section.icon,
order=section.order,
is_super_admin_only=section.is_super_admin_only,
is_collapsible=section.is_collapsible,
items=[],
)
# Add items from this module to the section
for item in section.items:
discovered_item = DiscoveredMenuItem(
id=item.id,
label_key=item.label_key,
icon=item.icon,
route=item.route,
order=item.order,
is_mandatory=item.is_mandatory,
requires_permission=item.requires_permission,
badge_source=item.badge_source,
is_super_admin_only=item.is_super_admin_only,
module_code=module_code,
section_id=section.id,
section_label_key=section.label_key,
section_order=section.order,
is_module_enabled=is_module_enabled,
)
sections_map[section.id].items.append(discovered_item)
# Sort sections by order
sorted_sections = sorted(sections_map.values(), key=lambda s: s.order)
# Sort items within each section
for section in sorted_sections:
section.items.sort(key=lambda i: i.order)
return sorted_sections
def get_menu_for_frontend(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
is_super_admin: bool = False,
vendor_code: str | None = None,
) -> list[DiscoveredMenuSection]:
"""
Get filtered menu structure for frontend rendering.
Applies all filters:
1. Module enablement (disabled modules = hidden items)
2. Visibility configuration (AdminMenuConfig preferences)
3. Super admin status (hides super_admin_only items for non-super-admins)
4. Permission requirements (future: filter by user permissions)
Args:
db: Database session
frontend_type: Frontend type (ADMIN, VENDOR, etc.)
platform_id: Platform ID for module enablement and visibility
user_id: User ID for user-specific visibility (super admins only)
is_super_admin: Whether the user is a super admin
vendor_code: Vendor code for route placeholder replacement
Returns:
List of DiscoveredMenuSection with filtered and sorted items
"""
# Get all sections with module enablement filtering
sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id)
# Get visibility configuration
visible_item_ids = self._get_visible_item_ids(
db, frontend_type, platform_id, user_id
)
# Filter sections and items
filtered_sections = []
for section in sections:
# Skip super_admin_only sections for non-super-admins
if section.is_super_admin_only and not is_super_admin:
continue
# Filter items
filtered_items = []
for item in section.items:
# Skip if module is disabled
if not item.is_module_enabled:
continue
# Skip super_admin_only items for non-super-admins
if item.is_super_admin_only and not is_super_admin:
continue
# Apply visibility (mandatory items always visible)
if visible_item_ids is not None and not item.is_mandatory:
if item.id not in visible_item_ids:
continue
# Resolve route placeholders
if vendor_code and "{vendor_code}" in item.route:
item.route = item.route.replace("{vendor_code}", vendor_code)
item.is_visible = True
filtered_items.append(item)
# Only include section if it has visible items
if filtered_items:
section.items = filtered_items
filtered_sections.append(section)
return filtered_sections
def _get_visible_item_ids(
self,
db: Session,
frontend_type: FrontendType,
platform_id: int | None = None,
user_id: int | None = None,
) -> set[str] | None:
"""
Get set of visible menu item IDs from AdminMenuConfig.
Returns:
Set of visible item IDs, or None if no config exists (default all visible)
"""
from app.modules.core.models import AdminMenuConfig
if not platform_id and not user_id:
return None
query = db.query(AdminMenuConfig).filter(
AdminMenuConfig.frontend_type == frontend_type,
)
if platform_id:
query = query.filter(AdminMenuConfig.platform_id == platform_id)
elif user_id:
query = query.filter(AdminMenuConfig.user_id == user_id)
configs = query.all()
if not configs:
return None # No config = all visible by default
return {c.menu_item_id for c in configs if c.is_visible}
def get_all_menu_items(
self,
frontend_type: FrontendType,
) -> list[DiscoveredMenuItem]:
"""
Get flat list of all menu items for a frontend type.
Useful for configuration UI where you need to show all possible items.
Does NOT filter by module enablement or visibility.
Args:
frontend_type: Frontend type to get items for
Returns:
Flat list of DiscoveredMenuItem from all modules
"""
from app.modules.registry import MODULES
items = []
for module_code, module_def in MODULES.items():
for section in module_def.menus.get(frontend_type, []):
for item in section.items:
discovered_item = DiscoveredMenuItem(
id=item.id,
label_key=item.label_key,
icon=item.icon,
route=item.route,
order=item.order,
is_mandatory=item.is_mandatory,
requires_permission=item.requires_permission,
badge_source=item.badge_source,
is_super_admin_only=item.is_super_admin_only,
module_code=module_code,
section_id=section.id,
section_label_key=section.label_key,
section_order=section.order,
)
items.append(discovered_item)
return sorted(items, key=lambda i: (i.section_order, i.order))
def get_mandatory_item_ids(
self,
frontend_type: FrontendType,
) -> set[str]:
"""
Get all mandatory menu item IDs for a frontend type.
Mandatory items cannot be hidden by users.
Args:
frontend_type: Frontend type to get mandatory items for
Returns:
Set of menu item IDs marked as is_mandatory=True
"""
from app.modules.registry import MODULES
mandatory_ids = set()
for module_def in MODULES.values():
for section in module_def.menus.get(frontend_type, []):
for item in section.items:
if item.is_mandatory:
mandatory_ids.add(item.id)
return mandatory_ids
def get_menu_item_module(
self,
menu_item_id: str,
frontend_type: FrontendType,
) -> str | None:
"""
Get the module code that provides a specific menu item.
Args:
menu_item_id: Menu item ID to look up
frontend_type: Frontend type to search in
Returns:
Module code, or None if not found
"""
from app.modules.registry import MODULES
for module_code, module_def in MODULES.items():
for section in module_def.menus.get(frontend_type, []):
for item in section.items:
if item.id == menu_item_id:
return module_code
return None
def menu_to_legacy_format(
self,
sections: list[DiscoveredMenuSection],
) -> dict:
"""
Convert discovered menu sections to legacy registry format.
This allows gradual migration by using new discovery with old rendering.
Args:
sections: List of DiscoveredMenuSection
Returns:
Dict in ADMIN_MENU_REGISTRY/VENDOR_MENU_REGISTRY format
"""
return {
"sections": [
{
"id": section.id,
"label": section.label_key, # Note: key not resolved
"super_admin_only": section.is_super_admin_only,
"items": [
{
"id": item.id,
"label": item.label_key, # Note: key not resolved
"icon": item.icon,
"url": item.route,
"super_admin_only": item.is_super_admin_only,
}
for item in section.items
],
}
for section in sections
]
}
# Singleton instance
menu_discovery_service = MenuDiscoveryService()
__all__ = [
"menu_discovery_service",
"MenuDiscoveryService",
"DiscoveredMenuItem",
"DiscoveredMenuSection",
]

View File

@@ -42,11 +42,9 @@ from app.config.menu_registry import (
is_super_admin_only_item, is_super_admin_only_item,
) )
from app.modules.service import module_service from app.modules.service import module_service
from models.database.admin_menu_config import ( from app.modules.core.models import AdminMenuConfig, MANDATORY_MENU_ITEMS
AdminMenuConfig, from app.modules.core.services.menu_discovery_service import menu_discovery_service
FrontendType, from app.modules.enums import FrontendType
MANDATORY_MENU_ITEMS,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -236,10 +234,13 @@ class MenuService:
platform_id: int | None = None, platform_id: int | None = None,
user_id: int | None = None, user_id: int | None = None,
is_super_admin: bool = False, is_super_admin: bool = False,
vendor_code: str | None = None,
) -> dict: ) -> dict:
""" """
Get filtered menu structure for frontend rendering. Get filtered menu structure for frontend rendering.
Uses MenuDiscoveryService to aggregate menus from all enabled modules.
Filters by: Filters by:
1. Module enablement (items from disabled modules are removed) 1. Module enablement (items from disabled modules are removed)
2. Visibility configuration 2. Visibility configuration
@@ -251,40 +252,23 @@ class MenuService:
platform_id: Platform ID (for platform admins and vendors) platform_id: Platform ID (for platform admins and vendors)
user_id: User ID (for super admins only) user_id: User ID (for super admins only)
is_super_admin: Whether user is super admin (affects admin-only sections) is_super_admin: Whether user is super admin (affects admin-only sections)
vendor_code: Vendor code for URL placeholder replacement (vendor frontend)
Returns: Returns:
Filtered menu structure ready for rendering Filtered menu structure ready for rendering
""" """
registry = ( # Use the module-driven discovery service to get filtered menu
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY sections = menu_discovery_service.get_menu_for_frontend(
db=db,
frontend_type=frontend_type,
platform_id=platform_id,
user_id=user_id,
is_super_admin=is_super_admin,
vendor_code=vendor_code,
) )
visible_items = self.get_visible_menu_items(db, frontend_type, platform_id, user_id) # Convert to legacy format for backwards compatibility with existing templates
return menu_discovery_service.menu_to_legacy_format(sections)
# Deep copy to avoid modifying the registry
filtered_menu = deepcopy(registry)
filtered_sections = []
for section in filtered_menu["sections"]:
# Skip super_admin_only sections if user is not super admin
if section.get("super_admin_only") and not is_super_admin:
continue
# Filter items to only visible ones
# Also skip super_admin_only items if user is not super admin
filtered_items = [
item for item in section["items"]
if item["id"] in visible_items
and (not item.get("super_admin_only") or is_super_admin)
]
# Only include section if it has visible items
if filtered_items:
section["items"] = filtered_items
filtered_sections.append(section)
filtered_menu["sections"] = filtered_sections
return filtered_menu
# ========================================================================= # =========================================================================
# Menu Configuration (Super Admin) # Menu Configuration (Super Admin)

View File

@@ -17,7 +17,7 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from models.database.admin import AdminSetting from app.modules.tenancy.models import AdminSetting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -25,6 +25,9 @@ function adminDashboard() {
* Initialize dashboard * Initialize dashboard
*/ */
async init() { async init() {
// Load i18n translations
await I18n.loadModule('core');
// Guard against multiple initialization // Guard against multiple initialization
if (window._dashboardInitialized) { if (window._dashboardInitialized) {
dashLog.warn('Dashboard already initialized, skipping...'); dashLog.warn('Dashboard already initialized, skipping...');
@@ -79,7 +82,7 @@ function adminDashboard() {
} catch (error) { } catch (error) {
window.LogConfig.logError(error, 'Dashboard Load'); window.LogConfig.logError(error, 'Dashboard Load');
this.error = error.message; this.error = error.message;
Utils.showToast('Failed to load dashboard data', 'error'); Utils.showToast(I18n.t('core.messages.failed_to_load_dashboard_data'), 'error');
} finally { } finally {
this.loading = false; this.loading = false;
@@ -182,7 +185,7 @@ function adminDashboard() {
async refresh() { async refresh() {
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ==='); dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
await this.loadDashboard(); await this.loadDashboard();
Utils.showToast('Dashboard refreshed', 'success'); Utils.showToast(I18n.t('core.messages.dashboard_refreshed'), 'success');
dashLog.info('=== DASHBOARD REFRESH COMPLETE ==='); dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
} }
}; };

View File

@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.modules.core.services.platform_settings_service import platform_settings_service from app.modules.core.services.platform_settings_service import platform_settings_service
from app.utils.i18n import get_jinja2_globals from app.utils.i18n import get_jinja2_globals
from models.database.user import User from app.modules.tenancy.models import User
from models.database.vendor import Vendor from app.modules.tenancy.models import Vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -6,8 +6,8 @@ Defines the customers module including its features, menu items,
route configurations, and self-contained module settings. route configurations, and self-contained module settings.
""" """
from app.modules.base import ModuleDefinition from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
from models.database.admin_menu_config import FrontendType from app.modules.enums import FrontendType
def _get_admin_router(): def _get_admin_router():
@@ -46,6 +46,43 @@ customers_module = ModuleDefinition(
"customers", # Vendor customer list "customers", # Vendor customer list
], ],
}, },
# New module-driven menu definitions
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="vendorOps",
label_key="customers.menu.vendor_operations",
icon="user-group",
order=40,
items=[
MenuItemDefinition(
id="customers",
label_key="customers.menu.customers",
icon="user-group",
route="/admin/customers",
order=20,
),
],
),
],
FrontendType.VENDOR: [
MenuSectionDefinition(
id="customers",
label_key="customers.menu.customers_section",
icon="user-group",
order=30,
items=[
MenuItemDefinition(
id="customers",
label_key="customers.menu.all_customers",
icon="user-group",
route="/vendor/{vendor_code}/customers",
order=10,
),
],
),
],
},
is_core=True, # Customers is a core module - customer data is fundamental is_core=True, # Customers is a core module - customer data is fundamental
# ========================================================================= # =========================================================================
# Self-Contained Module Configuration # Self-Contained Module Configuration

View File

@@ -1 +1,21 @@
{} {
"customers": {
"title": "Kunden",
"customer": "Kunde",
"add_customer": "Kunde hinzufügen",
"edit_customer": "Kunde bearbeiten",
"customer_name": "Kundenname",
"customer_email": "Kunden-E-Mail",
"customer_phone": "Kundentelefon",
"customer_number": "Kundennummer",
"first_name": "Vorname",
"last_name": "Nachname",
"company": "Firma",
"total_orders": "Bestellungen gesamt",
"total_spent": "Gesamtausgaben",
"last_order": "Letzte Bestellung",
"registered": "Registriert",
"no_customers": "Keine Kunden gefunden",
"search_customers": "Kunden suchen..."
}
}

View File

@@ -1 +1,26 @@
{} {
"customers": {
"title": "Customers",
"customer": "Customer",
"add_customer": "Add Customer",
"edit_customer": "Edit Customer",
"customer_name": "Customer Name",
"customer_email": "Customer Email",
"customer_phone": "Customer Phone",
"customer_number": "Customer Number",
"first_name": "First Name",
"last_name": "Last Name",
"company": "Company",
"total_orders": "Total Orders",
"total_spent": "Total Spent",
"last_order": "Last Order",
"registered": "Registered",
"no_customers": "No customers found",
"search_customers": "Search customers..."
},
"messages": {
"failed_to_toggle_customer_status": "Failed to toggle customer status",
"failed_to_load_customer_details": "Failed to load customer details",
"failed_to_load_customer_orders": "Failed to load customer orders"
}
}

View File

@@ -1 +1,21 @@
{} {
"customers": {
"title": "Clients",
"customer": "Client",
"add_customer": "Ajouter un client",
"edit_customer": "Modifier le client",
"customer_name": "Nom du client",
"customer_email": "E-mail du client",
"customer_phone": "Téléphone du client",
"customer_number": "Numéro client",
"first_name": "Prénom",
"last_name": "Nom",
"company": "Entreprise",
"total_orders": "Total des commandes",
"total_spent": "Total dépensé",
"last_order": "Dernière commande",
"registered": "Inscrit",
"no_customers": "Aucun client trouvé",
"search_customers": "Rechercher des clients..."
}
}

View File

@@ -1 +1,21 @@
{} {
"customers": {
"title": "Clienten",
"customer": "Client",
"add_customer": "Client derbäisetzen",
"edit_customer": "Client änneren",
"customer_name": "Clientennumm",
"customer_email": "Client E-Mail",
"customer_phone": "Client Telefon",
"customer_number": "Clientennummer",
"first_name": "Virnumm",
"last_name": "Nonumm",
"company": "Firma",
"total_orders": "Bestellungen insgesamt",
"total_spent": "Total ausginn",
"last_order": "Lescht Bestellung",
"registered": "Registréiert",
"no_customers": "Keng Clienten fonnt",
"search_customers": "Clienten sichen..."
}
}

Some files were not shown because too many files have changed in this diff Show More