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:
@@ -6,8 +6,8 @@ Defines the analytics module including its features, menu items,
|
||||
route configurations, and self-contained module settings.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
def _get_vendor_api_router():
|
||||
@@ -45,6 +45,27 @@ analytics_module = ModuleDefinition(
|
||||
"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,
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
|
||||
@@ -25,7 +25,7 @@ from app.modules.analytics.schemas import (
|
||||
VendorAnalyticsResponse,
|
||||
)
|
||||
from app.modules.billing.models import FeatureCode
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/analytics",
|
||||
|
||||
@@ -15,8 +15,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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.modules.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ from app.modules.inventory.models import Inventory
|
||||
from app.modules.marketplace.models import MarketplaceImportJob, MarketplaceProduct
|
||||
from app.modules.orders.models import Order
|
||||
from app.modules.catalog.models import Product
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.catalog.models import Product
|
||||
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__)
|
||||
|
||||
|
||||
@@ -47,6 +47,93 @@ if TYPE_CHECKING:
|
||||
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
|
||||
class ScheduledTask:
|
||||
"""
|
||||
@@ -190,6 +277,14 @@ class ModuleDefinition:
|
||||
menu_items: dict[FrontendType, list[str]] = field(default_factory=dict)
|
||||
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
|
||||
# =========================================================================
|
||||
@@ -235,15 +330,15 @@ class ModuleDefinition:
|
||||
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]:
|
||||
"""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, [])
|
||||
|
||||
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()
|
||||
for items in self.menu_items.values():
|
||||
all_items.update(items)
|
||||
@@ -253,6 +348,50 @@ class ModuleDefinition:
|
||||
"""Check if this module provides a specific menu item."""
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
@@ -6,8 +6,8 @@ Defines the billing module including its features, menu items,
|
||||
route configurations, and scheduled tasks.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition, ScheduledTask
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition, ScheduledTask
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
@@ -53,6 +53,72 @@ billing_module = ModuleDefinition(
|
||||
"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)
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
|
||||
@@ -89,7 +89,17 @@
|
||||
"payment_method_updated": "Zahlungsmethode aktualisiert",
|
||||
"subscription_cancelled": "Abonnement gekündigt",
|
||||
"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": {
|
||||
"orders_exceeded": "Monatliches Bestelllimit erreicht. Upgrade für mehr.",
|
||||
|
||||
@@ -89,7 +89,17 @@
|
||||
"payment_method_updated": "Payment method updated",
|
||||
"subscription_cancelled": "Subscription cancelled",
|
||||
"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": {
|
||||
"orders_exceeded": "Monthly order limit reached. Upgrade to continue.",
|
||||
|
||||
@@ -89,7 +89,17 @@
|
||||
"payment_method_updated": "Moyen de paiement mis à jour",
|
||||
"subscription_cancelled": "Abonnement annulé",
|
||||
"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": {
|
||||
"orders_exceeded": "Limite mensuelle de commandes atteinte. Passez à un niveau supérieur.",
|
||||
|
||||
@@ -89,7 +89,17 @@
|
||||
"payment_method_updated": "Zuelungsmethod aktualiséiert",
|
||||
"subscription_cancelled": "Abonnement gekënnegt",
|
||||
"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": {
|
||||
"orders_exceeded": "Monatlech Bestellungslimit erreecht. Upgrade fir méi.",
|
||||
|
||||
@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_admin_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
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 (
|
||||
BillingHistoryListResponse,
|
||||
BillingHistoryWithVendor,
|
||||
|
||||
@@ -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.database import get_db
|
||||
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__)
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from app.modules.billing.models import (
|
||||
VendorSubscription,
|
||||
)
|
||||
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__)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.modules.billing.models import (
|
||||
VendorAddOn,
|
||||
VendorSubscription,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.modules.billing.models import (
|
||||
SubscriptionStatus,
|
||||
VendorSubscription,
|
||||
)
|
||||
from models.database.vendor import Vendor, VendorUser
|
||||
from app.modules.tenancy.models import Vendor, VendorUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from app.modules.billing.models import (
|
||||
SubscriptionTier,
|
||||
VendorSubscription,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -308,7 +308,7 @@ class StripeService:
|
||||
customer_id = subscription.stripe_customer_id
|
||||
else:
|
||||
# Get vendor owner email
|
||||
from models.database.vendor import VendorUser
|
||||
from app.modules.tenancy.models import VendorUser
|
||||
|
||||
owner = (
|
||||
db.query(VendorUser)
|
||||
|
||||
@@ -46,7 +46,7 @@ from app.modules.billing.schemas import (
|
||||
UsageSummary,
|
||||
)
|
||||
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__)
|
||||
|
||||
|
||||
23
app/modules/billing/static/vendor/js/billing.js
vendored
23
app/modules/billing/static/vendor/js/billing.js
vendored
@@ -29,6 +29,9 @@ function vendorBilling() {
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('billing');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._vendorBillingInitialized) return;
|
||||
window._vendorBillingInitialized = true;
|
||||
@@ -81,7 +84,7 @@ function vendorBilling() {
|
||||
|
||||
} catch (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 {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -101,7 +104,7 @@ function vendorBilling() {
|
||||
}
|
||||
} catch (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) {
|
||||
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;
|
||||
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();
|
||||
|
||||
} catch (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() {
|
||||
try {
|
||||
await apiClient.post('/vendor/billing/reactivate', {});
|
||||
Utils.showToast('Subscription reactivated!', 'success');
|
||||
Utils.showToast(I18n.t('billing.messages.subscription_reactivated'), 'success');
|
||||
await this.loadData();
|
||||
|
||||
} catch (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) {
|
||||
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 {
|
||||
this.purchasingAddon = null;
|
||||
}
|
||||
@@ -172,11 +175,11 @@ function vendorBilling() {
|
||||
|
||||
try {
|
||||
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();
|
||||
} catch (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');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{# Standalone Pricing Page #}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ _("platform.pricing.title") }} - Wizamart{% endblock %}
|
||||
{% block title %}{{ _("cms.platform.pricing.title") }} - Wizamart{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="{ annual: false }" class="py-16 lg:py-24">
|
||||
@@ -10,15 +10,15 @@
|
||||
{# Header #}
|
||||
<div class="text-center mb-12">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{# Billing Toggle #}
|
||||
<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"
|
||||
class="relative w-14 h-7 rounded-full transition-colors"
|
||||
:class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'">
|
||||
@@ -26,8 +26,8 @@
|
||||
:class="annual ? 'translate-x-7' : ''"></span>
|
||||
</button>
|
||||
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }">
|
||||
{{ _("platform.pricing.annual") }}
|
||||
<span class="text-green-600 text-sm font-medium ml-1">{{ _("platform.pricing.save_months") }}</span>
|
||||
{{ _("cms.platform.pricing.annual") }}
|
||||
<span class="text-green-600 text-sm font-medium ml-1">{{ _("cms.platform.pricing.save_months") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
{% if tier.is_popular %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
@@ -50,17 +50,17 @@
|
||||
<template x-if="!annual">
|
||||
<div>
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="annual">
|
||||
<div>
|
||||
{% 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-gray-500">{{ _("platform.pricing.per_month") }}</span>
|
||||
<div class="text-sm text-gray-500">{{ tier.price_annual }}{{ _("platform.pricing.per_year") }}</div>
|
||||
<span class="text-gray-500">{{ _("cms.platform.pricing.per_month") }}</span>
|
||||
<div class="text-sm text-gray-500">{{ tier.price_annual }}{{ _("cms.platform.pricing.per_year") }}</div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</template>
|
||||
@@ -71,37 +71,37 @@
|
||||
<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"/>
|
||||
</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 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">
|
||||
<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>
|
||||
{% 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 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">
|
||||
<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>
|
||||
{% 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 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">
|
||||
<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>
|
||||
{{ _("platform.pricing.letzshop_sync") }}
|
||||
{{ _("cms.platform.pricing.letzshop_sync") }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% 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">
|
||||
{{ _("platform.pricing.contact_sales") }}
|
||||
{{ _("cms.platform.pricing.contact_sales") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a :href="'/signup?tier={{ tier.code }}&annual=' + annual"
|
||||
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 %}">
|
||||
{{ _("platform.pricing.start_trial") }}
|
||||
{{ _("cms.platform.pricing.start_trial") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
{# Back to Home #}
|
||||
<div class="text-center mt-12">
|
||||
<a href="/" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
← {{ _("platform.pricing.back_home") }}
|
||||
← {{ _("cms.platform.pricing.back_home") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{# Signup Success Page #}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ _("platform.success.title") }}{% endblock %}
|
||||
{% block title %}{{ _("cms.platform.success.title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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 #}
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("platform.success.title") }}
|
||||
{{ _("cms.platform.success.title") }}
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
|
||||
{# 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">
|
||||
<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">
|
||||
<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">
|
||||
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">1</span>
|
||||
</div>
|
||||
<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>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
@@ -41,7 +41,7 @@
|
||||
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">2</span>
|
||||
</div>
|
||||
<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>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
@@ -49,7 +49,7 @@
|
||||
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">3</span>
|
||||
</div>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -59,7 +59,7 @@
|
||||
{% if vendor_code %}
|
||||
<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">
|
||||
{{ _("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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
@@ -67,14 +67,14 @@
|
||||
{% else %}
|
||||
<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">
|
||||
{{ _("platform.success.login_dashboard") }}
|
||||
{{ _("cms.platform.success.login_dashboard") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Support Link #}
|
||||
<p class="mt-8 text-gray-500 dark:text-gray-400">
|
||||
{{ _("platform.success.need_help") }}
|
||||
<a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("platform.success.contact_support") }}</a>
|
||||
{{ _("cms.platform.success.need_help") }}
|
||||
<a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("cms.platform.success.contact_support") }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.modules.cart.schemas import (
|
||||
UpdateCartItemRequest,
|
||||
)
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# app/modules/catalog/definition.py
|
||||
"""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(
|
||||
code="catalog",
|
||||
@@ -10,4 +11,25 @@ module = ModuleDefinition(
|
||||
version="1.0.0",
|
||||
is_self_contained=True,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
{
|
||||
"title": "Produktkatalog",
|
||||
"description": "Produktkatalogverwaltung für Händler",
|
||||
"products": {
|
||||
"title": "Produkte",
|
||||
"subtitle": "Verwalten Sie Ihren Produktkatalog",
|
||||
"create": "Produkt erstellen",
|
||||
"edit": "Produkt bearbeiten",
|
||||
"delete": "Produkt löschen",
|
||||
"empty": "Keine Produkte gefunden",
|
||||
"empty_search": "Keine Produkte entsprechen Ihrer Suche"
|
||||
},
|
||||
"product": {
|
||||
"name": "Produktname",
|
||||
"description": "Beschreibung",
|
||||
"sku": "Artikelnummer",
|
||||
"product": "Produkt",
|
||||
"add_product": "Produkt hinzufügen",
|
||||
"edit_product": "Produkt bearbeiten",
|
||||
"delete_product": "Produkt löschen",
|
||||
"product_name": "Produktname",
|
||||
"product_code": "Produktcode",
|
||||
"sku": "SKU",
|
||||
"price": "Preis",
|
||||
"stock": "Bestand",
|
||||
"status": "Status",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv"
|
||||
},
|
||||
"media": {
|
||||
"title": "Produktmedien",
|
||||
"upload": "Bild hochladen",
|
||||
"delete": "Bild löschen",
|
||||
"primary": "Als Hauptbild festlegen",
|
||||
"error": "Medien-Upload fehlgeschlagen"
|
||||
},
|
||||
"validation": {
|
||||
"name_required": "Produktname ist erforderlich",
|
||||
"price_required": "Preis ist erforderlich",
|
||||
"invalid_sku": "Ungültiges Artikelnummernformat",
|
||||
"duplicate_sku": "Artikelnummer existiert bereits"
|
||||
"sale_price": "Verkaufspreis",
|
||||
"cost": "Kosten",
|
||||
"stock": "Lagerbestand",
|
||||
"in_stock": "Auf Lager",
|
||||
"out_of_stock": "Nicht auf Lager",
|
||||
"low_stock": "Geringer Bestand",
|
||||
"availability": "Verfügbarkeit",
|
||||
"available": "Verfügbar",
|
||||
"unavailable": "Nicht verfügbar",
|
||||
"brand": "Marke",
|
||||
"category": "Kategorie",
|
||||
"categories": "Kategorien",
|
||||
"image": "Bild",
|
||||
"images": "Bilder",
|
||||
"main_image": "Hauptbild",
|
||||
"gallery": "Galerie",
|
||||
"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": {
|
||||
"created": "Produkt erfolgreich erstellt",
|
||||
"updated": "Produkt erfolgreich aktualisiert",
|
||||
"deleted": "Produkt erfolgreich gelöscht",
|
||||
"not_found": "Produkt nicht gefunden",
|
||||
"cannot_delete": "Produkt kann nicht gelöscht werden",
|
||||
"error_loading": "Fehler beim Laden der Produkte"
|
||||
},
|
||||
"filters": {
|
||||
"all_products": "Alle Produkte",
|
||||
"active_only": "Nur aktive",
|
||||
"search_placeholder": "Produkte suchen..."
|
||||
"product_deleted_successfully": "Product deleted successfully",
|
||||
"please_fill_in_all_required_fields": "Please fill in all required fields",
|
||||
"product_updated_successfully": "Product updated successfully",
|
||||
"failed_to_load_media_library": "Failed to load media library",
|
||||
"no_vendor_associated_with_this_product": "No vendor associated with this product",
|
||||
"please_select_an_image_file": "Please select an image file",
|
||||
"image_must_be_less_than_10mb": "Image must be less than 10MB",
|
||||
"image_uploaded_successfully": "Image uploaded successfully",
|
||||
"product_removed_from_vendor_catalog": "Product removed from vendor catalog.",
|
||||
"please_select_a_vendor": "Please select a vendor",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
{
|
||||
"title": "Catalogue produits",
|
||||
"description": "Gestion du catalogue produits pour les vendeurs",
|
||||
"products": {
|
||||
"title": "Produits",
|
||||
"subtitle": "Gérez votre catalogue de produits",
|
||||
"create": "Créer un produit",
|
||||
"edit": "Modifier le produit",
|
||||
"delete": "Supprimer le produit",
|
||||
"empty": "Aucun produit trouvé",
|
||||
"empty_search": "Aucun produit ne correspond à votre recherche"
|
||||
},
|
||||
"product": {
|
||||
"name": "Nom du produit",
|
||||
"description": "Description",
|
||||
"sku": "Référence",
|
||||
"product": "Produit",
|
||||
"add_product": "Ajouter un produit",
|
||||
"edit_product": "Modifier le produit",
|
||||
"delete_product": "Supprimer le produit",
|
||||
"product_name": "Nom du produit",
|
||||
"product_code": "Code produit",
|
||||
"sku": "SKU",
|
||||
"price": "Prix",
|
||||
"sale_price": "Prix de vente",
|
||||
"cost": "Coût",
|
||||
"stock": "Stock",
|
||||
"status": "Statut",
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif"
|
||||
},
|
||||
"media": {
|
||||
"title": "Médias du produit",
|
||||
"upload": "Télécharger une image",
|
||||
"delete": "Supprimer l'image",
|
||||
"primary": "Définir comme image principale",
|
||||
"error": "Échec du téléchargement"
|
||||
},
|
||||
"validation": {
|
||||
"name_required": "Le nom du produit est requis",
|
||||
"price_required": "Le prix est requis",
|
||||
"invalid_sku": "Format de référence invalide",
|
||||
"duplicate_sku": "La référence existe déjà"
|
||||
"in_stock": "En stock",
|
||||
"out_of_stock": "Rupture de stock",
|
||||
"low_stock": "Stock faible",
|
||||
"availability": "Disponibilité",
|
||||
"available": "Disponible",
|
||||
"unavailable": "Indisponible",
|
||||
"brand": "Marque",
|
||||
"category": "Catégorie",
|
||||
"categories": "Catégories",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
"main_image": "Image principale",
|
||||
"gallery": "Galerie",
|
||||
"weight": "Poids",
|
||||
"dimensions": "Dimensions",
|
||||
"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": {
|
||||
"created": "Produit créé avec succès",
|
||||
"updated": "Produit mis à jour avec succès",
|
||||
"deleted": "Produit supprimé avec succès",
|
||||
"not_found": "Produit non trouvé",
|
||||
"cannot_delete": "Impossible de supprimer le produit",
|
||||
"error_loading": "Erreur lors du chargement des produits"
|
||||
},
|
||||
"filters": {
|
||||
"all_products": "Tous les produits",
|
||||
"active_only": "Actifs uniquement",
|
||||
"search_placeholder": "Rechercher des produits..."
|
||||
"product_deleted_successfully": "Product deleted successfully",
|
||||
"please_fill_in_all_required_fields": "Please fill in all required fields",
|
||||
"product_updated_successfully": "Product updated successfully",
|
||||
"failed_to_load_media_library": "Failed to load media library",
|
||||
"no_vendor_associated_with_this_product": "No vendor associated with this product",
|
||||
"please_select_an_image_file": "Please select an image file",
|
||||
"image_must_be_less_than_10mb": "Image must be less than 10MB",
|
||||
"image_uploaded_successfully": "Image uploaded successfully",
|
||||
"product_removed_from_vendor_catalog": "Product removed from vendor catalog.",
|
||||
"please_select_a_vendor": "Please select a vendor",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
{
|
||||
"title": "Produktkatalog",
|
||||
"description": "Produktkatalogverwaltung fir Händler",
|
||||
"products": {
|
||||
"title": "Produkter",
|
||||
"subtitle": "Verwalte Äre Produktkatalog",
|
||||
"create": "Produkt erstellen",
|
||||
"edit": "Produkt beaarbechten",
|
||||
"delete": "Produkt läschen",
|
||||
"empty": "Keng Produkter fonnt",
|
||||
"empty_search": "Keng Produkter entspriechen Ärer Sich"
|
||||
},
|
||||
"product": {
|
||||
"name": "Produktnumm",
|
||||
"description": "Beschreiwung",
|
||||
"sku": "Artikelnummer",
|
||||
"product": "Produkt",
|
||||
"add_product": "Produkt derbäisetzen",
|
||||
"edit_product": "Produkt änneren",
|
||||
"delete_product": "Produkt läschen",
|
||||
"product_name": "Produktnumm",
|
||||
"product_code": "Produktcode",
|
||||
"sku": "SKU",
|
||||
"price": "Präis",
|
||||
"stock": "Bestand",
|
||||
"status": "Status",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv"
|
||||
},
|
||||
"media": {
|
||||
"title": "Produktmedien",
|
||||
"upload": "Bild eroplueden",
|
||||
"delete": "Bild läschen",
|
||||
"primary": "Als Haaptbild setzen",
|
||||
"error": "Medien-Upload feelgeschloen"
|
||||
},
|
||||
"validation": {
|
||||
"name_required": "Produktnumm ass erfuerderlech",
|
||||
"price_required": "Präis ass erfuerderlech",
|
||||
"invalid_sku": "Ongëlteg Artikelnummerformat",
|
||||
"duplicate_sku": "Artikelnummer existéiert schonn"
|
||||
"sale_price": "Verkafspräis",
|
||||
"cost": "Käschten",
|
||||
"stock": "Lager",
|
||||
"in_stock": "Op Lager",
|
||||
"out_of_stock": "Net op Lager",
|
||||
"low_stock": "Niddregen Stock",
|
||||
"availability": "Disponibilitéit",
|
||||
"available": "Disponibel",
|
||||
"unavailable": "Net disponibel",
|
||||
"brand": "Mark",
|
||||
"category": "Kategorie",
|
||||
"categories": "Kategorien",
|
||||
"image": "Bild",
|
||||
"images": "Biller",
|
||||
"main_image": "Haaptbild",
|
||||
"gallery": "Galerie",
|
||||
"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": {
|
||||
"created": "Produkt erfollegräich erstallt",
|
||||
"updated": "Produkt erfollegräich aktualiséiert",
|
||||
"deleted": "Produkt erfollegräich geläscht",
|
||||
"not_found": "Produkt net fonnt",
|
||||
"cannot_delete": "Produkt kann net geläscht ginn",
|
||||
"error_loading": "Feeler beim Lueden vun de Produkter"
|
||||
},
|
||||
"filters": {
|
||||
"all_products": "All Produkter",
|
||||
"active_only": "Nëmmen aktiv",
|
||||
"search_placeholder": "Produkter sichen..."
|
||||
"product_deleted_successfully": "Product deleted successfully",
|
||||
"please_fill_in_all_required_fields": "Please fill in all required fields",
|
||||
"product_updated_successfully": "Product updated successfully",
|
||||
"failed_to_load_media_library": "Failed to load media library",
|
||||
"no_vendor_associated_with_this_product": "No vendor associated with this product",
|
||||
"please_select_an_image_file": "Please select an image file",
|
||||
"image_must_be_less_than_10mb": "Image must be less than 10MB",
|
||||
"image_uploaded_successfully": "Image uploaded successfully",
|
||||
"product_removed_from_vendor_catalog": "Product removed from vendor catalog.",
|
||||
"please_select_a_vendor": "Please select a vendor",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.modules.catalog.schemas import (
|
||||
ProductResponse,
|
||||
)
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,8 +15,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.catalog.exceptions import ProductNotFoundException
|
||||
from app.modules.catalog.models import Product
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@ function adminVendorProductCreate() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('catalog');
|
||||
|
||||
adminVendorProductCreateLog.info('Vendor Product Create init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
@@ -166,12 +169,12 @@ function adminVendorProductCreate() {
|
||||
*/
|
||||
async createProduct() {
|
||||
if (!this.form.vendor_id) {
|
||||
Utils.showToast('Please select a vendor', 'error');
|
||||
Utils.showToast(I18n.t('catalog.messages.please_select_a_vendor'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -224,7 +227,7 @@ function adminVendorProductCreate() {
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
@@ -232,7 +235,7 @@ function adminVendorProductCreate() {
|
||||
}, 1000);
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -274,7 +277,7 @@ function adminVendorProductCreate() {
|
||||
this.mediaPickerState.total = response.total || 0;
|
||||
} catch (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 {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
@@ -326,17 +329,17 @@ function adminVendorProductCreate() {
|
||||
const vendorId = this.form?.vendor_id;
|
||||
|
||||
if (!vendorId) {
|
||||
Utils.showToast('Please select a vendor first', 'error');
|
||||
Utils.showToast(I18n.t('catalog.messages.please_select_a_vendor_first'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -355,11 +358,11 @@ function adminVendorProductCreate() {
|
||||
this.mediaPickerState.media.unshift(response.media);
|
||||
this.mediaPickerState.total++;
|
||||
this.toggleMediaSelection(response.media);
|
||||
Utils.showToast('Image uploaded successfully', 'success');
|
||||
Utils.showToast(I18n.t('catalog.messages.image_uploaded_successfully'), 'success');
|
||||
}
|
||||
} catch (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 {
|
||||
this.mediaPickerState.uploading = false;
|
||||
event.target.value = '';
|
||||
|
||||
@@ -76,6 +76,9 @@ function adminVendorProductEdit() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('catalog');
|
||||
|
||||
adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId);
|
||||
|
||||
// Guard against multiple initialization
|
||||
@@ -209,7 +212,7 @@ function adminVendorProductEdit() {
|
||||
*/
|
||||
async saveProduct() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -266,7 +269,7 @@ function adminVendorProductEdit() {
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
@@ -274,7 +277,7 @@ function adminVendorProductEdit() {
|
||||
}, 1000);
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -316,7 +319,7 @@ function adminVendorProductEdit() {
|
||||
this.mediaPickerState.total = response.total || 0;
|
||||
} catch (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 {
|
||||
this.mediaPickerState.loading = false;
|
||||
}
|
||||
@@ -368,17 +371,17 @@ function adminVendorProductEdit() {
|
||||
const vendorId = this.product?.vendor_id;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -397,11 +400,11 @@ function adminVendorProductEdit() {
|
||||
this.mediaPickerState.media.unshift(response.media);
|
||||
this.mediaPickerState.total++;
|
||||
this.toggleMediaSelection(response.media);
|
||||
Utils.showToast('Image uploaded successfully', 'success');
|
||||
Utils.showToast(I18n.t('catalog.messages.image_uploaded_successfully'), 'success');
|
||||
}
|
||||
} catch (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 {
|
||||
this.mediaPickerState.uploading = false;
|
||||
event.target.value = '';
|
||||
|
||||
@@ -116,6 +116,9 @@ function adminVendorProducts() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('catalog');
|
||||
|
||||
adminVendorProductsLog.info('Vendor Products init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
@@ -385,7 +388,7 @@ function adminVendorProducts() {
|
||||
this.productToRemove = null;
|
||||
|
||||
// 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
|
||||
await this.refresh();
|
||||
|
||||
35
app/modules/catalog/static/vendor/js/products.js
vendored
35
app/modules/catalog/static/vendor/js/products.js
vendored
@@ -112,6 +112,9 @@ function vendorProducts() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('catalog');
|
||||
|
||||
vendorProductsLog.info('Products init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
@@ -230,13 +233,13 @@ function vendorProducts() {
|
||||
await apiClient.put(`/vendor/products/${product.id}/toggle-active`);
|
||||
product.is_active = !product.is_active;
|
||||
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'
|
||||
);
|
||||
vendorProductsLog.info('Toggled product active:', product.id, product.is_active);
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -251,13 +254,13 @@ function vendorProducts() {
|
||||
await apiClient.put(`/vendor/products/${product.id}/toggle-featured`);
|
||||
product.is_featured = !product.is_featured;
|
||||
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'
|
||||
);
|
||||
vendorProductsLog.info('Toggled product featured:', product.id, product.is_featured);
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -288,7 +291,7 @@ function vendorProducts() {
|
||||
this.saving = true;
|
||||
try {
|
||||
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);
|
||||
|
||||
this.showDeleteModal = false;
|
||||
@@ -296,7 +299,7 @@ function vendorProducts() {
|
||||
await this.loadProducts();
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -417,12 +420,12 @@ function vendorProducts() {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) activated`, 'success');
|
||||
Utils.showToast(I18n.t('catalog.messages.products_activated', { count: successCount }), 'success');
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -445,12 +448,12 @@ function vendorProducts() {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) deactivated`, 'success');
|
||||
Utils.showToast(I18n.t('catalog.messages.products_deactivated', { count: successCount }), 'success');
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -473,12 +476,12 @@ function vendorProducts() {
|
||||
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();
|
||||
await this.loadProducts();
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -501,12 +504,12 @@ function vendorProducts() {
|
||||
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();
|
||||
await this.loadProducts();
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -533,13 +536,13 @@ function vendorProducts() {
|
||||
await apiClient.delete(`/vendor/products/${productId}`);
|
||||
successCount++;
|
||||
}
|
||||
Utils.showToast(`${successCount} product(s) deleted`, 'success');
|
||||
Utils.showToast(I18n.t('catalog.messages.products_deleted', { count: successCount }), 'success');
|
||||
this.showBulkDeleteModal = false;
|
||||
this.clearSelection();
|
||||
await this.loadProducts();
|
||||
} catch (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 {
|
||||
this.saving = false;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
{
|
||||
"title": "Kasse",
|
||||
"description": "Bestellabwicklung und Zahlungsabwicklung",
|
||||
"session": {
|
||||
"title": "Checkout-Sitzung",
|
||||
"expired": "Sitzung abgelaufen",
|
||||
"invalid": "Ungültige Sitzung"
|
||||
},
|
||||
"shipping": {
|
||||
"title": "Lieferadresse",
|
||||
"select_address": "Adresse auswählen",
|
||||
"add_new": "Neue Adresse hinzufügen",
|
||||
"method": "Versandart",
|
||||
"select_method": "Versandart auswählen",
|
||||
"not_available": "Für diese Adresse nicht verfügbar"
|
||||
},
|
||||
"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": {
|
||||
"storefront": {
|
||||
"welcome": "Willkommen in unserem Shop",
|
||||
"browse_products": "Produkte durchstöbern",
|
||||
"add_to_cart": "In den Warenkorb",
|
||||
"buy_now": "Jetzt kaufen",
|
||||
"view_cart": "Warenkorb ansehen",
|
||||
"checkout": "Zur Kasse",
|
||||
"continue_shopping": "Weiter einkaufen",
|
||||
"start_shopping": "Einkaufen starten",
|
||||
"empty_cart": "Ihr Warenkorb ist leer",
|
||||
"cart_total": "Warenkorbsumme",
|
||||
"proceed_checkout": "Zur Kasse gehen",
|
||||
"payment": "Zahlung",
|
||||
"place_order": "Bestellung aufgeben",
|
||||
"order_placed": "Bestellung erfolgreich aufgegeben",
|
||||
"checkout_failed": "Checkout fehlgeschlagen",
|
||||
"session_expired": "Ihre Sitzung ist abgelaufen",
|
||||
"inventory_error": "Einige Artikel sind nicht mehr verfügbar"
|
||||
"thank_you": "Vielen Dank für Ihre Bestellung",
|
||||
"order_confirmation": "Bestellbestätigung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
{
|
||||
"title": "Checkout",
|
||||
"description": "Order checkout and payment processing",
|
||||
"session": {
|
||||
"title": "Checkout Session",
|
||||
"expired": "Session expired",
|
||||
"invalid": "Invalid session"
|
||||
},
|
||||
"shipping": {
|
||||
"title": "Shipping Address",
|
||||
"select_address": "Select Address",
|
||||
"add_new": "Add New Address",
|
||||
"method": "Shipping Method",
|
||||
"select_method": "Select Shipping Method",
|
||||
"not_available": "Not available for this address"
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment",
|
||||
"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"
|
||||
"storefront": {
|
||||
"welcome": "Welcome to our store",
|
||||
"browse_products": "Browse Products",
|
||||
"add_to_cart": "Add to Cart",
|
||||
"buy_now": "Buy Now",
|
||||
"view_cart": "View Cart",
|
||||
"checkout": "Checkout",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"start_shopping": "Start Shopping",
|
||||
"empty_cart": "Your cart is empty",
|
||||
"cart_total": "Cart Total",
|
||||
"proceed_checkout": "Proceed to Checkout",
|
||||
"payment": "Payment",
|
||||
"place_order": "Place Order",
|
||||
"order_placed": "Order Placed Successfully",
|
||||
"thank_you": "Thank you for your order",
|
||||
"order_confirmation": "Order Confirmation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
{
|
||||
"title": "Caisse",
|
||||
"description": "Traitement des commandes et des paiements",
|
||||
"session": {
|
||||
"title": "Session de paiement",
|
||||
"expired": "Session expirée",
|
||||
"invalid": "Session invalide"
|
||||
},
|
||||
"shipping": {
|
||||
"title": "Adresse de livraison",
|
||||
"select_address": "Sélectionner une adresse",
|
||||
"add_new": "Ajouter une nouvelle adresse",
|
||||
"method": "Mode de livraison",
|
||||
"select_method": "Sélectionner un mode de livraison",
|
||||
"not_available": "Non disponible pour cette adresse"
|
||||
},
|
||||
"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": {
|
||||
"storefront": {
|
||||
"welcome": "Bienvenue dans notre boutique",
|
||||
"browse_products": "Parcourir les produits",
|
||||
"add_to_cart": "Ajouter au panier",
|
||||
"buy_now": "Acheter maintenant",
|
||||
"view_cart": "Voir le panier",
|
||||
"checkout": "Paiement",
|
||||
"continue_shopping": "Continuer vos achats",
|
||||
"start_shopping": "Commencer vos achats",
|
||||
"empty_cart": "Votre panier est vide",
|
||||
"cart_total": "Total du panier",
|
||||
"proceed_checkout": "Passer à la caisse",
|
||||
"payment": "Paiement",
|
||||
"place_order": "Passer la commande",
|
||||
"order_placed": "Commande passée avec succès",
|
||||
"checkout_failed": "Échec du paiement",
|
||||
"session_expired": "Votre session a expiré",
|
||||
"inventory_error": "Certains articles ne sont plus disponibles"
|
||||
"thank_you": "Merci pour votre commande",
|
||||
"order_confirmation": "Confirmation de commande"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
{
|
||||
"title": "Keess",
|
||||
"description": "Bestellungsofwécklung a Bezuelung",
|
||||
"session": {
|
||||
"title": "Checkout-Sëtzung",
|
||||
"expired": "Sëtzung ofgelaf",
|
||||
"invalid": "Ongëlteg Sëtzung"
|
||||
},
|
||||
"shipping": {
|
||||
"title": "Liwweradress",
|
||||
"select_address": "Adress auswielen",
|
||||
"add_new": "Nei Adress derbäisetzen",
|
||||
"method": "Liwwermethod",
|
||||
"select_method": "Liwwermethod auswielen",
|
||||
"not_available": "Net verfügbar fir dës Adress"
|
||||
},
|
||||
"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": {
|
||||
"storefront": {
|
||||
"welcome": "Wëllkomm an eisem Buttek",
|
||||
"browse_products": "Produkter duerchsichen",
|
||||
"add_to_cart": "An de Kuerf",
|
||||
"buy_now": "Elo kafen",
|
||||
"view_cart": "Kuerf kucken",
|
||||
"checkout": "Bezuelen",
|
||||
"continue_shopping": "Weider akafen",
|
||||
"start_shopping": "Ufänken mat Akafen",
|
||||
"empty_cart": "Äre Kuerf ass eidel",
|
||||
"cart_total": "Kuerf Total",
|
||||
"proceed_checkout": "Zur Bezuelung goen",
|
||||
"payment": "Bezuelung",
|
||||
"place_order": "Bestellung opgi",
|
||||
"order_placed": "Bestellung erfollegräich opginn",
|
||||
"checkout_failed": "Checkout feelgeschloen",
|
||||
"session_expired": "Är Sëtzung ass ofgelaf",
|
||||
"inventory_error": "E puer Artikelen sinn net méi verfügbar"
|
||||
"thank_you": "Merci fir Är Bestellung",
|
||||
"order_confirmation": "Bestellungsbestätegung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.modules.customers.schemas import CustomerContext
|
||||
from app.modules.orders.services import order_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 models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
from app.modules.orders.schemas import OrderCreate, OrderResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -12,8 +12,8 @@ This is a self-contained module with:
|
||||
- Templates: app.modules.cms.templates (namespaced as cms/)
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
@@ -53,6 +53,57 @@ cms_module = ModuleDefinition(
|
||||
"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
|
||||
# Self-contained module configuration
|
||||
is_self_contained=True,
|
||||
|
||||
@@ -1,126 +1,203 @@
|
||||
{
|
||||
"title": "Content-Verwaltung",
|
||||
"description": "Verwalten Sie Inhaltsseiten, Medienbibliothek und Händler-Themes",
|
||||
"pages": {
|
||||
"title": "Inhaltsseiten",
|
||||
"subtitle": "Verwalten Sie Plattform- und Händler-Inhaltsseiten",
|
||||
"create": "Seite erstellen",
|
||||
"edit": "Seite bearbeiten",
|
||||
"delete": "Seite löschen",
|
||||
"list": "Alle Seiten",
|
||||
"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": {
|
||||
"title": "Hero-Abschnitt",
|
||||
"badge_text": "Badge-Text",
|
||||
"main_title": "Titel",
|
||||
"subtitle": "Untertitel",
|
||||
"buttons": "Schaltflächen",
|
||||
"add_button": "Schaltfläche hinzufügen"
|
||||
"platform": {
|
||||
"nav": {
|
||||
"pricing": "Preise",
|
||||
"find_shop": "Finden Sie Ihren Shop",
|
||||
"start_trial": "Kostenlos testen",
|
||||
"admin_login": "Admin-Login",
|
||||
"vendor_login": "Händler-Login",
|
||||
"toggle_menu": "Menü umschalten",
|
||||
"toggle_dark_mode": "Dunkelmodus umschalten"
|
||||
},
|
||||
"features": {
|
||||
"title": "Funktionen-Abschnitt",
|
||||
"section_title": "Abschnittstitel",
|
||||
"cards": "Funktionskarten",
|
||||
"add_card": "Karte hinzufügen",
|
||||
"icon": "Icon-Name",
|
||||
"feature_title": "Titel",
|
||||
"feature_description": "Beschreibung"
|
||||
"hero": {
|
||||
"badge": "{trial_days}-Tage kostenlose Testversion - Keine Kreditkarte erforderlich",
|
||||
"title": "Leichtes OMS für Letzshop-Verkäufer",
|
||||
"subtitle": "Bestellverwaltung, Lager und Rechnungsstellung für den luxemburgischen E-Commerce. Schluss mit Tabellenkalkulationen. Führen Sie Ihr Geschäft.",
|
||||
"cta_trial": "Kostenlos testen",
|
||||
"cta_find_shop": "Finden Sie Ihren Letzshop"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Preise-Abschnitt",
|
||||
"section_title": "Abschnittstitel",
|
||||
"use_tiers": "Abonnement-Stufen aus der Datenbank verwenden",
|
||||
"use_tiers_help": "Wenn aktiviert, werden Preiskarten dynamisch aus Ihrer Abonnement-Stufenkonfiguration abgerufen."
|
||||
"title": "Einfache, transparente Preise",
|
||||
"subtitle": "Wählen Sie den Plan, der zu Ihrem Unternehmen passt. Alle Pläne beinhalten eine {trial_days}-tägige kostenlose Testversion.",
|
||||
"monthly": "Monatlich",
|
||||
"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": {
|
||||
"title": "Call-to-Action-Abschnitt",
|
||||
"main_title": "Titel",
|
||||
"subtitle": "Untertitel",
|
||||
"buttons": "Schaltflächen",
|
||||
"add_button": "Schaltfläche hinzufügen"
|
||||
"title": "Bereit, Ihre Bestellungen zu optimieren?",
|
||||
"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.",
|
||||
"button": "Kostenlos testen"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Leichtes OMS für Letzshop-Verkäufer. Verwalten Sie Bestellungen, Lager und Rechnungen.",
|
||||
"quick_links": "Schnelllinks",
|
||||
"platform": "Plattform",
|
||||
"contact": "Kontakt",
|
||||
"copyright": "© {year} Wizamart. Entwickelt für den luxemburgischen E-Commerce.",
|
||||
"privacy": "Datenschutzerklärung",
|
||||
"terms": "Nutzungsbedingungen",
|
||||
"about": "Über uns",
|
||||
"faq": "FAQ",
|
||||
"contact_us": "Kontaktieren Sie uns"
|
||||
},
|
||||
"modern": {
|
||||
"badge_integration": "Offizielle Integration",
|
||||
"badge_connect": "In 2 Minuten verbinden",
|
||||
"hero_title_1": "Für den luxemburgischen E-Commerce entwickelt",
|
||||
"hero_title_2": "Das Back-Office, das Letzshop Ihnen nicht gibt",
|
||||
"hero_subtitle": "Synchronisieren Sie Bestellungen, verwalten Sie Lager, erstellen Sie Rechnungen mit korrekter MwSt und besitzen Sie Ihre Kundendaten. Alles an einem Ort.",
|
||||
"cta_trial": "{trial_days}-Tage kostenlos testen",
|
||||
"cta_how": "Sehen Sie, wie es funktioniert",
|
||||
"hero_note": "Keine Kreditkarte erforderlich. Einrichtung in 5 Minuten. Jederzeit kündbar.",
|
||||
"pain_title": "Kommt Ihnen das bekannt vor?",
|
||||
"pain_subtitle": "Das sind die täglichen Frustrationen von Letzshop-Verkäufern",
|
||||
"pain_manual": "Manuelle Bestelleingabe",
|
||||
"pain_manual_desc": "Bestellungen von Letzshop in Tabellenkalkulationen kopieren. Jeden. Einzelnen. Tag.",
|
||||
"pain_inventory": "Lagerchaos",
|
||||
"pain_inventory_desc": "Der Bestand in Letzshop stimmt nicht mit der Realität überein. Überverkäufe passieren.",
|
||||
"pain_vat": "Falsche MwSt-Rechnungen",
|
||||
"pain_vat_desc": "EU-Kunden brauchen die korrekte MwSt. Ihr Buchhalter beschwert sich.",
|
||||
"pain_customers": "Verlorene Kunden",
|
||||
"pain_customers_desc": "Letzshop besitzt Ihre Kundendaten. Sie können nicht retargeten oder Loyalität aufbauen.",
|
||||
"how_title": "So funktioniert es",
|
||||
"how_subtitle": "Vom Chaos zur Kontrolle in 4 Schritten",
|
||||
"how_step1": "Letzshop verbinden",
|
||||
"how_step1_desc": "Geben Sie Ihre Letzshop-API-Zugangsdaten ein. In 2 Minuten erledigt, keine technischen Kenntnisse erforderlich.",
|
||||
"how_step2": "Bestellungen kommen rein",
|
||||
"how_step2_desc": "Bestellungen werden automatisch synchronisiert. Bestätigen und Tracking direkt von Wizamart hinzufügen.",
|
||||
"how_step3": "Rechnungen erstellen",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"title": "Medienbibliothek",
|
||||
"upload": "Hochladen",
|
||||
"upload_file": "Datei hochladen",
|
||||
"delete": "Löschen",
|
||||
"empty": "Keine Mediendateien",
|
||||
"upload_first": "Laden Sie Ihre erste Datei hoch"
|
||||
},
|
||||
"themes": {
|
||||
"title": "Händler-Themes",
|
||||
"subtitle": "Verwalten Sie Händler-Theme-Anpassungen"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern...",
|
||||
"update": "Seite aktualisieren",
|
||||
"create": "Seite erstellen",
|
||||
"cancel": "Abbrechen",
|
||||
"back_to_list": "Zurück zur Liste",
|
||||
"preview": "Vorschau",
|
||||
"revert_to_default": "Auf Standard zurücksetzen"
|
||||
},
|
||||
"messages": {
|
||||
"created": "Seite erfolgreich erstellt",
|
||||
"updated": "Seite erfolgreich aktualisiert",
|
||||
"deleted": "Seite erfolgreich gelöscht",
|
||||
"reverted": "Auf Standardseite zurückgesetzt",
|
||||
"error_loading": "Fehler beim Laden der Seite",
|
||||
"error_saving": "Fehler beim Speichern der Seite",
|
||||
"confirm_delete": "Sind Sie sicher, dass Sie diese Seite löschen möchten?"
|
||||
},
|
||||
"filters": {
|
||||
"all_pages": "Alle Seiten",
|
||||
"all_platforms": "Alle Plattformen",
|
||||
"search_placeholder": "Seiten suchen..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +1,203 @@
|
||||
{
|
||||
"title": "Gestion de contenu",
|
||||
"description": "Gestion des pages de contenu, de la bibliothèque de médias et des thèmes",
|
||||
"pages": {
|
||||
"title": "Pages de contenu",
|
||||
"subtitle": "Gérez les pages de contenu de la plateforme et des vendeurs",
|
||||
"create": "Créer une page",
|
||||
"edit": "Modifier la page",
|
||||
"delete": "Supprimer la page",
|
||||
"list": "Toutes les pages",
|
||||
"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": {
|
||||
"title": "Section Hero",
|
||||
"badge_text": "Texte du badge",
|
||||
"main_title": "Titre",
|
||||
"subtitle": "Sous-titre",
|
||||
"buttons": "Boutons",
|
||||
"add_button": "Ajouter un bouton"
|
||||
"platform": {
|
||||
"nav": {
|
||||
"pricing": "Tarifs",
|
||||
"find_shop": "Trouvez votre boutique",
|
||||
"start_trial": "Essai gratuit",
|
||||
"admin_login": "Connexion Admin",
|
||||
"vendor_login": "Connexion Vendeur",
|
||||
"toggle_menu": "Basculer le menu",
|
||||
"toggle_dark_mode": "Basculer le mode sombre"
|
||||
},
|
||||
"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"
|
||||
"hero": {
|
||||
"badge": "Essai gratuit de {trial_days} jours - Aucune carte de crédit requise",
|
||||
"title": "OMS léger pour les vendeurs Letzshop",
|
||||
"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.",
|
||||
"cta_trial": "Essai gratuit",
|
||||
"cta_find_shop": "Trouvez votre boutique Letzshop"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Section Tarifs",
|
||||
"section_title": "Titre de la section",
|
||||
"use_tiers": "Utiliser les niveaux d'abonnement de la base de données",
|
||||
"use_tiers_help": "Si activé, les cartes de tarifs sont extraites dynamiquement de la configuration des niveaux d'abonnement."
|
||||
"title": "Tarification simple et transparente",
|
||||
"subtitle": "Choisissez le plan adapté à votre entreprise. Tous les plans incluent un essai gratuit de {trial_days} jours.",
|
||||
"monthly": "Mensuel",
|
||||
"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": {
|
||||
"title": "Section Appel à l'action",
|
||||
"main_title": "Titre",
|
||||
"subtitle": "Sous-titre",
|
||||
"buttons": "Boutons",
|
||||
"add_button": "Ajouter un bouton"
|
||||
"title": "Prêt à optimiser vos commandes ?",
|
||||
"subtitle": "Rejoignez les vendeurs Letzshop qui font confiance à Wizamart pour leur gestion de commandes. Commencez votre essai gratuit de {trial_days} jours aujourd'hui.",
|
||||
"button": "Essai gratuit"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "OMS léger pour les vendeurs Letzshop. Gérez commandes, stocks et facturation.",
|
||||
"quick_links": "Liens rapides",
|
||||
"platform": "Plateforme",
|
||||
"contact": "Contact",
|
||||
"copyright": "© {year} Wizamart. Conçu pour le e-commerce luxembourgeois.",
|
||||
"privacy": "Politique de confidentialité",
|
||||
"terms": "Conditions d'utilisation",
|
||||
"about": "À propos",
|
||||
"faq": "FAQ",
|
||||
"contact_us": "Nous contacter"
|
||||
},
|
||||
"modern": {
|
||||
"badge_integration": "Intégration officielle",
|
||||
"badge_connect": "Connexion en 2 minutes",
|
||||
"hero_title_1": "Conçu pour le e-commerce luxembourgeois",
|
||||
"hero_title_2": "Le back-office que Letzshop ne vous donne pas",
|
||||
"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.",
|
||||
"cta_trial": "Essai gratuit de {trial_days} jours",
|
||||
"cta_how": "Voir comment ça marche",
|
||||
"hero_note": "Aucune carte de crédit requise. Configuration en 5 minutes. Annulez à tout moment.",
|
||||
"pain_title": "Ça vous dit quelque chose ?",
|
||||
"pain_subtitle": "Ce sont les frustrations quotidiennes des vendeurs Letzshop",
|
||||
"pain_manual": "Saisie manuelle des commandes",
|
||||
"pain_manual_desc": "Copier-coller les commandes de Letzshop vers des tableurs. Chaque. Jour.",
|
||||
"pain_inventory": "Chaos des stocks",
|
||||
"pain_inventory_desc": "Le stock dans Letzshop ne correspond pas à la réalité. Les surventes arrivent.",
|
||||
"pain_vat": "Mauvaises factures TVA",
|
||||
"pain_vat_desc": "Les clients UE ont besoin de la TVA correcte. Votre comptable se plaint.",
|
||||
"pain_customers": "Clients perdus",
|
||||
"pain_customers_desc": "Letzshop possède vos données clients. Vous ne pouvez pas les recibler ou fidéliser.",
|
||||
"how_title": "Comment ça marche",
|
||||
"how_subtitle": "Du chaos au contrôle en 4 étapes",
|
||||
"how_step1": "Connecter Letzshop",
|
||||
"how_step1_desc": "Entrez vos identifiants API Letzshop. Fait en 2 minutes, aucune compétence technique requise.",
|
||||
"how_step2": "Les commandes arrivent",
|
||||
"how_step2_desc": "Les commandes se synchronisent automatiquement. Confirmez et ajoutez le suivi directement depuis Wizamart.",
|
||||
"how_step3": "Générer des factures",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"title": "Bibliothèque de médias",
|
||||
"upload": "Télécharger",
|
||||
"upload_file": "Télécharger un fichier",
|
||||
"delete": "Supprimer",
|
||||
"empty": "Aucun fichier média",
|
||||
"upload_first": "Téléchargez votre premier fichier"
|
||||
},
|
||||
"themes": {
|
||||
"title": "Thèmes vendeurs",
|
||||
"subtitle": "Gérez les personnalisations de thèmes des vendeurs"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"update": "Mettre à jour la page",
|
||||
"create": "Créer la page",
|
||||
"cancel": "Annuler",
|
||||
"back_to_list": "Retour à la liste",
|
||||
"preview": "Aperçu",
|
||||
"revert_to_default": "Revenir à la valeur par défaut"
|
||||
},
|
||||
"messages": {
|
||||
"created": "Page créée avec succès",
|
||||
"updated": "Page mise à jour avec succès",
|
||||
"deleted": "Page supprimée avec succès",
|
||||
"reverted": "Retour à la page par défaut",
|
||||
"error_loading": "Erreur lors du chargement de la page",
|
||||
"error_saving": "Erreur lors de l'enregistrement de la page",
|
||||
"confirm_delete": "Êtes-vous sûr de vouloir supprimer cette page ?"
|
||||
},
|
||||
"filters": {
|
||||
"all_pages": "Toutes les pages",
|
||||
"all_platforms": "Toutes les plateformes",
|
||||
"search_placeholder": "Rechercher des pages..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +1,203 @@
|
||||
{
|
||||
"title": "Inhalts-Verwaltung",
|
||||
"description": "Verwaltet Inhaltsäiten, Mediebibliothéik an Händler-Themen",
|
||||
"pages": {
|
||||
"title": "Inhaltsäiten",
|
||||
"subtitle": "Verwaltet Plattform- an Händler-Inhaltsäiten",
|
||||
"create": "Säit erstellen",
|
||||
"edit": "Säit änneren",
|
||||
"delete": "Säit läschen",
|
||||
"list": "All Säiten",
|
||||
"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": {
|
||||
"title": "Hero-Sektioun",
|
||||
"badge_text": "Badge-Text",
|
||||
"main_title": "Titel",
|
||||
"subtitle": "Ënnertitel",
|
||||
"buttons": "Knäpp",
|
||||
"add_button": "Knapp derbäisetzen"
|
||||
"platform": {
|
||||
"nav": {
|
||||
"pricing": "Präisser",
|
||||
"find_shop": "Fannt Äre Buttek",
|
||||
"start_trial": "Gratis Testen",
|
||||
"admin_login": "Admin Login",
|
||||
"vendor_login": "Händler Login",
|
||||
"toggle_menu": "Menü wiesselen",
|
||||
"toggle_dark_mode": "Däischter Modus wiesselen"
|
||||
},
|
||||
"features": {
|
||||
"title": "Funktiounen-Sektioun",
|
||||
"section_title": "Sektiounstitel",
|
||||
"cards": "Funktiounskaarten",
|
||||
"add_card": "Kaart derbäisetzen",
|
||||
"icon": "Icon-Numm",
|
||||
"feature_title": "Titel",
|
||||
"feature_description": "Beschreiwung"
|
||||
"hero": {
|
||||
"badge": "{trial_days}-Deeg gratis Testversioun - Keng Kreditkaart néideg",
|
||||
"title": "Liichtt OMS fir Letzshop Verkeefer",
|
||||
"subtitle": "Bestellungsverwaltung, Lager an Rechnungsstellung fir de lëtzebuergeschen E-Commerce. Schluss mat Tabellen. Féiert Äert Geschäft.",
|
||||
"cta_trial": "Gratis Testen",
|
||||
"cta_find_shop": "Fannt Äre Letzshop Buttek"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Präisser-Sektioun",
|
||||
"section_title": "Sektiounstitel",
|
||||
"use_tiers": "Abonnement-Stufen aus der Datebank benotzen",
|
||||
"use_tiers_help": "Wann aktivéiert, ginn d'Präiskaarten dynamesch aus Ärer Abonnement-Stufekonfiguratioun ofgeruff."
|
||||
"title": "Einfach, transparent Präisser",
|
||||
"subtitle": "Wielt de Plang deen zu Ärer Firma passt. All Pläng enthalen eng {trial_days}-Deeg gratis Testversioun.",
|
||||
"monthly": "Monatslech",
|
||||
"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": {
|
||||
"title": "Call-to-Action-Sektioun",
|
||||
"main_title": "Titel",
|
||||
"subtitle": "Ënnertitel",
|
||||
"buttons": "Knäpp",
|
||||
"add_button": "Knapp derbäisetzen"
|
||||
"title": "Prett fir Är Bestellungen ze optiméieren?",
|
||||
"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.",
|
||||
"button": "Gratis Testen"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Liichtt OMS fir Letzshop Verkeefer. Verwaltt Bestellungen, Lager an Rechnungen.",
|
||||
"quick_links": "Séier Linken",
|
||||
"platform": "Plattform",
|
||||
"contact": "Kontakt",
|
||||
"copyright": "© {year} Wizamart. Gemaach fir de lëtzebuergeschen E-Commerce.",
|
||||
"privacy": "Dateschutzrichtlinn",
|
||||
"terms": "Notzungsbedéngungen",
|
||||
"about": "Iwwer eis",
|
||||
"faq": "FAQ",
|
||||
"contact_us": "Kontaktéiert eis"
|
||||
},
|
||||
"modern": {
|
||||
"badge_integration": "Offiziell Integratioun",
|
||||
"badge_connect": "An 2 Minutten verbannen",
|
||||
"hero_title_1": "Gemaach fir de lëtzebuergeschen E-Commerce",
|
||||
"hero_title_2": "De Back-Office dee Letzshop Iech net gëtt",
|
||||
"hero_subtitle": "Synchroniséiert Bestellungen, verwaltt Lager, erstellt Rechnunge mat der korrekter TVA a besëtzt Är Clientsdaten. Alles un engem Plaz.",
|
||||
"cta_trial": "{trial_days}-Deeg gratis testen",
|
||||
"cta_how": "Kuckt wéi et funktionéiert",
|
||||
"hero_note": "Keng Kreditkaart néideg. Setup an 5 Minutten. Ëmmer kënnegen.",
|
||||
"pain_title": "Kënnt Iech dat bekannt vir?",
|
||||
"pain_subtitle": "Dat sinn d'deeglech Frustratioune vu Letzshop Verkeefer",
|
||||
"pain_manual": "Manuell Bestellungsagab",
|
||||
"pain_manual_desc": "Bestellunge vu Letzshop an Tabelle kopéieren. All. Eenzelen. Dag.",
|
||||
"pain_inventory": "Lager Chaos",
|
||||
"pain_inventory_desc": "De Stock an Letzshop stëmmt net mat der Realitéit iwwereneen. Iwwerverkeef passéieren.",
|
||||
"pain_vat": "Falsch TVA Rechnungen",
|
||||
"pain_vat_desc": "EU Cliente brauchen déi korrekt TVA. Äre Comptabel beschwéiert sech.",
|
||||
"pain_customers": "Verluer Clienten",
|
||||
"pain_customers_desc": "Letzshop besëtzt Är Clientsdaten. Dir kënnt se net retargeten oder Loyalitéit opbauen.",
|
||||
"how_title": "Wéi et funktionéiert",
|
||||
"how_subtitle": "Vum Chaos zur Kontroll an 4 Schrëtt",
|
||||
"how_step1": "Letzshop verbannen",
|
||||
"how_step1_desc": "Gitt Är Letzshop API Zougangsdaten an. An 2 Minutte fäerdeg, keng technesch Kenntnisser néideg.",
|
||||
"how_step2": "Bestellunge kommen eran",
|
||||
"how_step2_desc": "Bestellunge ginn automatesch synchroniséiert. Confirméiert an Tracking direkt vu Wizamart derbäisetzen.",
|
||||
"how_step3": "Rechnunge generéieren",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"title": "Mediebibliothéik",
|
||||
"upload": "Eroplueden",
|
||||
"upload_file": "Fichier eroplueden",
|
||||
"delete": "Läschen",
|
||||
"empty": "Keng Mediefichieren",
|
||||
"upload_first": "Luet Äre éischte Fichier erop"
|
||||
},
|
||||
"themes": {
|
||||
"title": "Händler-Themen",
|
||||
"subtitle": "Verwaltet Händler-Theme-Personnalisatiounen"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Späicheren",
|
||||
"saving": "Späicheren...",
|
||||
"update": "Säit aktualiséieren",
|
||||
"create": "Säit erstellen",
|
||||
"cancel": "Ofbriechen",
|
||||
"back_to_list": "Zréck op d'Lëscht",
|
||||
"preview": "Virschau",
|
||||
"revert_to_default": "Op Standard zrécksetzen"
|
||||
},
|
||||
"messages": {
|
||||
"created": "Säit erfollegräich erstallt",
|
||||
"updated": "Säit erfollegräich aktualiséiert",
|
||||
"deleted": "Säit erfollegräich geläscht",
|
||||
"reverted": "Op Standardsäit zréckgesat",
|
||||
"error_loading": "Feeler beim Lueden vun der Säit",
|
||||
"error_saving": "Feeler beim Späichere vun der Säit",
|
||||
"confirm_delete": "Sidd Dir sécher, datt Dir dës Säit läsche wëllt?"
|
||||
},
|
||||
"filters": {
|
||||
"all_pages": "All Säiten",
|
||||
"all_platforms": "All Plattformen",
|
||||
"search_placeholder": "Säite sichen..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
"""
|
||||
CMS module database models.
|
||||
|
||||
This is the canonical location for CMS models. Module models are automatically
|
||||
discovered and registered with SQLAlchemy's Base.metadata at startup.
|
||||
This is the canonical location for CMS models including:
|
||||
- ContentPage: CMS pages (marketing, vendor default pages)
|
||||
- MediaFile: Vendor media library
|
||||
- VendorTheme: Vendor storefront theme configuration
|
||||
|
||||
Usage:
|
||||
from app.modules.cms.models import ContentPage
|
||||
from app.modules.cms.models import ContentPage, MediaFile, VendorTheme
|
||||
|
||||
For media models:
|
||||
from models.database.media import MediaFile # Core media file storage
|
||||
from app.modules.catalog.models import ProductMedia # Product-media associations
|
||||
For product-media associations:
|
||||
from app.modules.catalog.models import ProductMedia
|
||||
"""
|
||||
|
||||
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__ = [
|
||||
"ContentPage",
|
||||
"MediaFile",
|
||||
"VendorTheme",
|
||||
]
|
||||
|
||||
124
app/modules/cms/models/media.py
Normal file
124
app/modules/cms/models/media.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# app/modules/cms/models/media.py
|
||||
"""
|
||||
CORE media file model for vendor media library.
|
||||
|
||||
This is a CORE framework model used across multiple modules.
|
||||
MediaFile provides vendor-uploaded media files (images, documents, videos).
|
||||
|
||||
For product-media associations, use:
|
||||
from app.modules.catalog.models import ProductMedia
|
||||
|
||||
Files are stored in vendor-specific directories:
|
||||
uploads/vendors/{vendor_id}/{folder}/{filename}
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class MediaFile(Base, TimestampMixin):
|
||||
"""Vendor media file record.
|
||||
|
||||
Stores metadata about uploaded files. Actual files are stored
|
||||
in the filesystem at uploads/vendors/{vendor_id}/{folder}/
|
||||
"""
|
||||
|
||||
__tablename__ = "media_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
|
||||
# File identification
|
||||
filename = Column(String(255), nullable=False) # Stored filename (UUID-based)
|
||||
original_filename = Column(String(255)) # Original uploaded filename
|
||||
file_path = Column(String(500), nullable=False) # Relative path from uploads/
|
||||
|
||||
# File properties
|
||||
media_type = Column(String(20), nullable=False) # image, video, document
|
||||
mime_type = Column(String(100))
|
||||
file_size = Column(Integer) # bytes
|
||||
|
||||
# Image/video dimensions
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
|
||||
# Thumbnail (for images/videos)
|
||||
thumbnail_path = Column(String(500))
|
||||
|
||||
# Metadata
|
||||
alt_text = Column(String(500))
|
||||
description = Column(Text)
|
||||
folder = Column(String(100), default="general") # products, general, etc.
|
||||
tags = Column(JSON) # List of tags for categorization
|
||||
extra_metadata = Column(JSON) # Additional metadata (EXIF, etc.)
|
||||
|
||||
# Status
|
||||
is_optimized = Column(Boolean, default=False)
|
||||
optimized_size = Column(Integer) # Size after optimization
|
||||
|
||||
# Usage tracking
|
||||
usage_count = Column(Integer, default=0) # How many times used
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="media_files")
|
||||
# ProductMedia relationship uses string reference to avoid circular import
|
||||
product_associations = relationship(
|
||||
"ProductMedia",
|
||||
back_populates="media",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_media_vendor_id", "vendor_id"),
|
||||
Index("idx_media_vendor_folder", "vendor_id", "folder"),
|
||||
Index("idx_media_vendor_type", "vendor_id", "media_type"),
|
||||
Index("idx_media_filename", "filename"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<MediaFile(id={self.id}, vendor_id={self.vendor_id}, "
|
||||
f"filename='{self.filename}', type='{self.media_type}')>"
|
||||
)
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
"""Get the public URL for this file."""
|
||||
return f"/uploads/{self.file_path}"
|
||||
|
||||
@property
|
||||
def thumbnail_url(self) -> str | None:
|
||||
"""Get the thumbnail URL if available."""
|
||||
if self.thumbnail_path:
|
||||
return f"/uploads/{self.thumbnail_path}"
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
"""Check if this is an image file."""
|
||||
return self.media_type == "image"
|
||||
|
||||
@property
|
||||
def is_video(self) -> bool:
|
||||
"""Check if this is a video file."""
|
||||
return self.media_type == "video"
|
||||
|
||||
@property
|
||||
def is_document(self) -> bool:
|
||||
"""Check if this is a document file."""
|
||||
return self.media_type == "document"
|
||||
|
||||
|
||||
__all__ = ["MediaFile"]
|
||||
139
app/modules/cms/models/vendor_theme.py
Normal file
139
app/modules/cms/models/vendor_theme.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# app/modules/cms/models/vendor_theme.py
|
||||
"""
|
||||
Vendor Theme Configuration Model
|
||||
Allows each vendor to customize their shop's appearance
|
||||
"""
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class VendorTheme(Base, TimestampMixin):
|
||||
"""
|
||||
Stores theme configuration for each vendor's shop.
|
||||
|
||||
Each vendor can have ONE active theme:
|
||||
- Custom colors (primary, secondary, accent)
|
||||
- Custom fonts
|
||||
- Custom logo and favicon
|
||||
- Custom CSS overrides
|
||||
- Layout preferences
|
||||
|
||||
Theme presets available: default, modern, classic, minimal, vibrant
|
||||
"""
|
||||
|
||||
__tablename__ = "vendor_themes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True, # ONE vendor = ONE theme
|
||||
)
|
||||
|
||||
# Basic Theme Settings
|
||||
theme_name = Column(
|
||||
String(100), default="default"
|
||||
) # default, modern, classic, minimal, vibrant
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Color Scheme (JSON for flexibility)
|
||||
colors = Column(
|
||||
JSON,
|
||||
default={
|
||||
"primary": "#6366f1", # Indigo
|
||||
"secondary": "#8b5cf6", # Purple
|
||||
"accent": "#ec4899", # Pink
|
||||
"background": "#ffffff", # White
|
||||
"text": "#1f2937", # Gray-800
|
||||
"border": "#e5e7eb", # Gray-200
|
||||
},
|
||||
)
|
||||
|
||||
# Typography
|
||||
font_family_heading = Column(String(100), default="Inter, sans-serif")
|
||||
font_family_body = Column(String(100), default="Inter, sans-serif")
|
||||
|
||||
# Branding Assets
|
||||
logo_url = Column(String(500), nullable=True) # Path to vendor logo
|
||||
logo_dark_url = Column(String(500), nullable=True) # Dark mode logo
|
||||
favicon_url = Column(String(500), nullable=True) # Favicon
|
||||
banner_url = Column(String(500), nullable=True) # Homepage banner
|
||||
|
||||
# Layout Preferences
|
||||
layout_style = Column(String(50), default="grid") # grid, list, masonry
|
||||
header_style = Column(String(50), default="fixed") # fixed, static, transparent
|
||||
product_card_style = Column(
|
||||
String(50), default="modern"
|
||||
) # modern, classic, minimal
|
||||
|
||||
# Custom CSS (for advanced customization)
|
||||
custom_css = Column(Text, nullable=True)
|
||||
|
||||
# Social Media Links
|
||||
social_links = Column(JSON, default={}) # {facebook: "url", instagram: "url", etc.}
|
||||
|
||||
# SEO & Meta
|
||||
meta_title_template = Column(
|
||||
String(200), nullable=True
|
||||
) # e.g., "{product_name} - {shop_name}"
|
||||
meta_description = Column(Text, nullable=True)
|
||||
|
||||
# Relationships - FIXED: back_populates must match the relationship name in Vendor model
|
||||
vendor = relationship("Vendor", back_populates="vendor_theme")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<VendorTheme(vendor_id={self.vendor_id}, theme_name='{self.theme_name}')>"
|
||||
)
|
||||
|
||||
@property
|
||||
def primary_color(self):
|
||||
"""Get primary color from JSON"""
|
||||
return self.colors.get("primary", "#6366f1")
|
||||
|
||||
@property
|
||||
def css_variables(self):
|
||||
"""Generate CSS custom properties from theme config"""
|
||||
return {
|
||||
"--color-primary": self.colors.get("primary", "#6366f1"),
|
||||
"--color-secondary": self.colors.get("secondary", "#8b5cf6"),
|
||||
"--color-accent": self.colors.get("accent", "#ec4899"),
|
||||
"--color-background": self.colors.get("background", "#ffffff"),
|
||||
"--color-text": self.colors.get("text", "#1f2937"),
|
||||
"--color-border": self.colors.get("border", "#e5e7eb"),
|
||||
"--font-heading": self.font_family_heading,
|
||||
"--font-body": self.font_family_body,
|
||||
}
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert theme to dictionary for template rendering"""
|
||||
return {
|
||||
"theme_name": self.theme_name,
|
||||
"colors": self.colors,
|
||||
"fonts": {
|
||||
"heading": self.font_family_heading,
|
||||
"body": self.font_family_body,
|
||||
},
|
||||
"branding": {
|
||||
"logo": self.logo_url,
|
||||
"logo_dark": self.logo_dark_url,
|
||||
"favicon": self.favicon_url,
|
||||
"banner": self.banner_url,
|
||||
},
|
||||
"layout": {
|
||||
"style": self.layout_style,
|
||||
"header": self.header_style,
|
||||
"product_card": self.product_card_style,
|
||||
},
|
||||
"social_links": self.social_links,
|
||||
"custom_css": self.custom_css,
|
||||
"css_variables": self.css_variables,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["VendorTheme"]
|
||||
@@ -23,7 +23,7 @@ from app.modules.cms.schemas import (
|
||||
SectionUpdateResponse,
|
||||
)
|
||||
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")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.modules.core.services.image_service import image_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.image import (
|
||||
from app.modules.cms.schemas.image import (
|
||||
ImageDeleteResponse,
|
||||
ImageStorageStats,
|
||||
ImageUploadResponse,
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.cms.services.media_service import media_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.media import (
|
||||
from app.modules.cms.schemas.media import (
|
||||
MediaDetailResponse,
|
||||
MediaItemResponse,
|
||||
MediaListResponse,
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_admin_api, get_db
|
||||
from app.modules.cms.services.vendor_theme_service import vendor_theme_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.vendor_theme import (
|
||||
from app.modules.cms.schemas.vendor_theme import (
|
||||
ThemeDeleteResponse,
|
||||
ThemePresetListResponse,
|
||||
ThemePresetResponse,
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.modules.cms.schemas import (
|
||||
)
|
||||
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 models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
vendor_service = VendorService()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.core.database import get_db
|
||||
from app.modules.cms.exceptions import MediaOptimizationException
|
||||
from app.modules.cms.services.media_service import media_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.media import (
|
||||
from app.modules.cms.schemas.media import (
|
||||
MediaDetailResponse,
|
||||
MediaItemResponse,
|
||||
MediaListResponse,
|
||||
|
||||
@@ -11,8 +11,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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.core.services.platform_settings_service import platform_settings_service # noqa: MOD-004 - shared platform service
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -35,6 +35,44 @@ from app.modules.cms.schemas.homepage_sections import (
|
||||
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__ = [
|
||||
# Content Page - Admin
|
||||
"ContentPageCreate",
|
||||
@@ -60,4 +98,33 @@ __all__ = [
|
||||
"HomepageSections",
|
||||
"SectionUpdateRequest",
|
||||
"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",
|
||||
]
|
||||
|
||||
46
app/modules/cms/schemas/image.py
Normal file
46
app/modules/cms/schemas/image.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# app/modules/cms/schemas/image.py
|
||||
"""
|
||||
Pydantic schemas for image operations.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ImageUrls(BaseModel):
|
||||
"""URLs for image variants."""
|
||||
|
||||
original: str
|
||||
medium: str | None = None # 800px variant
|
||||
thumb: str | None = None # 200px variant
|
||||
|
||||
# Allow arbitrary keys for flexibility
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class ImageUploadResponse(BaseModel):
|
||||
"""Response from image upload."""
|
||||
|
||||
success: bool
|
||||
image: dict | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ImageDeleteResponse(BaseModel):
|
||||
"""Response from image deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ImageStorageStats(BaseModel):
|
||||
"""Image storage statistics."""
|
||||
|
||||
total_files: int
|
||||
total_size_bytes: int
|
||||
total_size_mb: float
|
||||
total_size_gb: float
|
||||
directory_count: int
|
||||
max_files_per_dir: int
|
||||
avg_files_per_dir: float
|
||||
products_estimated: int
|
||||
198
app/modules/cms/schemas/media.py
Normal file
198
app/modules/cms/schemas/media.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# app/modules/cms/schemas/media.py
|
||||
"""
|
||||
Media/file management Pydantic schemas for API validation and responses.
|
||||
|
||||
This module provides schemas for:
|
||||
- Media library listing
|
||||
- File upload responses
|
||||
- Media metadata operations
|
||||
- Media usage tracking
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ============================================================================
|
||||
# SHARED RESPONSE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Generic message response for simple operations."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEDIA ITEM SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MediaItemResponse(BaseModel):
|
||||
"""Single media item response."""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
original_filename: str | None = None
|
||||
file_url: str
|
||||
url: str | None = None # Alias for file_url for JS compatibility
|
||||
thumbnail_url: str | None = None
|
||||
media_type: str # image, video, document
|
||||
mime_type: str | None = None
|
||||
file_size: int | None = None # bytes
|
||||
width: int | None = None # for images/videos
|
||||
height: int | None = None # for images/videos
|
||||
alt_text: str | None = None
|
||||
description: str | None = None
|
||||
folder: str | None = None
|
||||
extra_metadata: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Set url from file_url if not provided."""
|
||||
if self.url is None:
|
||||
object.__setattr__(self, "url", self.file_url)
|
||||
|
||||
|
||||
class MediaListResponse(BaseModel):
|
||||
"""Paginated list of media items."""
|
||||
|
||||
media: list[MediaItemResponse] = []
|
||||
total: int = 0
|
||||
skip: int = 0
|
||||
limit: int = 100
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# UPLOAD RESPONSE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MediaUploadResponse(BaseModel):
|
||||
"""Response for single file upload."""
|
||||
|
||||
success: bool = True
|
||||
message: str | None = None
|
||||
media: MediaItemResponse | None = None
|
||||
# Legacy fields for backwards compatibility
|
||||
id: int | None = None
|
||||
file_url: str | None = None
|
||||
thumbnail_url: str | None = None
|
||||
filename: str | None = None
|
||||
file_size: int | None = None
|
||||
media_type: str | None = None
|
||||
|
||||
|
||||
class UploadedFileInfo(BaseModel):
|
||||
"""Information about a successfully uploaded file."""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
file_url: str
|
||||
thumbnail_url: str | None = None
|
||||
|
||||
|
||||
class FailedFileInfo(BaseModel):
|
||||
"""Information about a failed file upload."""
|
||||
|
||||
filename: str
|
||||
error: str
|
||||
|
||||
|
||||
class MultipleUploadResponse(BaseModel):
|
||||
"""Response for multiple file upload."""
|
||||
|
||||
uploaded_files: list[UploadedFileInfo] = []
|
||||
failed_files: list[FailedFileInfo] = []
|
||||
total_uploaded: int = 0
|
||||
total_failed: int = 0
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEDIA DETAIL SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MediaDetailResponse(BaseModel):
|
||||
"""Detailed media item response with usage info."""
|
||||
|
||||
id: int | None = None
|
||||
filename: str | None = None
|
||||
original_filename: str | None = None
|
||||
file_url: str | None = None
|
||||
thumbnail_url: str | None = None
|
||||
media_type: str | None = None
|
||||
mime_type: str | None = None
|
||||
file_size: int | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
alt_text: str | None = None
|
||||
description: str | None = None
|
||||
folder: str | None = None
|
||||
extra_metadata: dict[str, Any] | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
message: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEDIA UPDATE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MediaMetadataUpdate(BaseModel):
|
||||
"""Request model for updating media metadata."""
|
||||
|
||||
filename: str | None = Field(None, max_length=255)
|
||||
alt_text: str | None = Field(None, max_length=500)
|
||||
description: str | None = None
|
||||
folder: str | None = Field(None, max_length=100)
|
||||
metadata: dict[str, Any] | None = None # Named 'metadata' in API, stored as 'extra_metadata'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEDIA USAGE SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ProductUsageInfo(BaseModel):
|
||||
"""Information about product using this media."""
|
||||
|
||||
product_id: int
|
||||
product_name: str
|
||||
usage_type: str # main_image, gallery, variant, etc.
|
||||
|
||||
|
||||
class MediaUsageResponse(BaseModel):
|
||||
"""Response showing where media is being used."""
|
||||
|
||||
media_id: int | None = None
|
||||
products: list[ProductUsageInfo] = []
|
||||
other_usage: list[dict[str, Any]] = []
|
||||
total_usage_count: int = 0
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEDIA OPTIMIZATION SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OptimizationResultResponse(BaseModel):
|
||||
"""Response for media optimization operation."""
|
||||
|
||||
media_id: int | None = None
|
||||
original_size: int | None = None
|
||||
optimized_size: int | None = None
|
||||
savings_percent: float | None = None
|
||||
optimized_url: str | None = None
|
||||
message: str | None = None
|
||||
108
app/modules/cms/schemas/vendor_theme.py
Normal file
108
app/modules/cms/schemas/vendor_theme.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# app/modules/cms/schemas/vendor_theme.py
|
||||
"""
|
||||
Pydantic schemas for vendor theme operations.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VendorThemeColors(BaseModel):
|
||||
"""Color scheme for vendor theme."""
|
||||
|
||||
primary: str | None = Field(None, description="Primary brand color")
|
||||
secondary: str | None = Field(None, description="Secondary color")
|
||||
accent: str | None = Field(None, description="Accent/CTA color")
|
||||
background: str | None = Field(None, description="Background color")
|
||||
text: str | None = Field(None, description="Text color")
|
||||
border: str | None = Field(None, description="Border color")
|
||||
|
||||
|
||||
class VendorThemeFonts(BaseModel):
|
||||
"""Typography settings for vendor theme."""
|
||||
|
||||
heading: str | None = Field(None, description="Font for headings")
|
||||
body: str | None = Field(None, description="Font for body text")
|
||||
|
||||
|
||||
class VendorThemeBranding(BaseModel):
|
||||
"""Branding assets for vendor theme."""
|
||||
|
||||
logo: str | None = Field(None, description="Logo URL")
|
||||
logo_dark: str | None = Field(None, description="Dark mode logo URL")
|
||||
favicon: str | None = Field(None, description="Favicon URL")
|
||||
banner: str | None = Field(None, description="Banner image URL")
|
||||
|
||||
|
||||
class VendorThemeLayout(BaseModel):
|
||||
"""Layout settings for vendor theme."""
|
||||
|
||||
style: str | None = Field(
|
||||
None, description="Product layout style (grid, list, masonry)"
|
||||
)
|
||||
header: str | None = Field(
|
||||
None, description="Header style (fixed, static, transparent)"
|
||||
)
|
||||
product_card: str | None = Field(
|
||||
None, description="Product card style (modern, classic, minimal)"
|
||||
)
|
||||
|
||||
|
||||
class VendorThemeUpdate(BaseModel):
|
||||
"""Schema for updating vendor theme (partial updates allowed)."""
|
||||
|
||||
theme_name: str | None = Field(None, description="Theme preset name")
|
||||
colors: dict[str, str] | None = Field(None, description="Color scheme")
|
||||
fonts: dict[str, str] | None = Field(None, description="Font settings")
|
||||
branding: dict[str, str | None] | None = Field(None, description="Branding assets")
|
||||
layout: dict[str, str] | None = Field(None, description="Layout settings")
|
||||
custom_css: str | None = Field(None, description="Custom CSS rules")
|
||||
social_links: dict[str, str] | None = Field(None, description="Social media links")
|
||||
|
||||
|
||||
class VendorThemeResponse(BaseModel):
|
||||
"""Schema for vendor theme response."""
|
||||
|
||||
theme_name: str = Field(..., description="Theme name")
|
||||
colors: dict[str, str] = Field(..., description="Color scheme")
|
||||
fonts: dict[str, str] = Field(..., description="Font settings")
|
||||
branding: dict[str, str | None] = Field(..., description="Branding assets")
|
||||
layout: dict[str, str] = Field(..., description="Layout settings")
|
||||
social_links: dict[str, str] | None = Field(
|
||||
default_factory=dict, description="Social links"
|
||||
)
|
||||
custom_css: str | None = Field(None, description="Custom CSS")
|
||||
css_variables: dict[str, str] | None = Field(
|
||||
None, description="CSS custom properties"
|
||||
)
|
||||
|
||||
|
||||
class ThemePresetPreview(BaseModel):
|
||||
"""Preview information for a theme preset."""
|
||||
|
||||
name: str = Field(..., description="Preset name")
|
||||
description: str = Field(..., description="Preset description")
|
||||
primary_color: str = Field(..., description="Primary color")
|
||||
secondary_color: str = Field(..., description="Secondary color")
|
||||
accent_color: str = Field(..., description="Accent color")
|
||||
heading_font: str = Field(..., description="Heading font")
|
||||
body_font: str = Field(..., description="Body font")
|
||||
layout_style: str = Field(..., description="Layout style")
|
||||
|
||||
|
||||
class ThemePresetResponse(BaseModel):
|
||||
"""Response after applying a preset."""
|
||||
|
||||
message: str = Field(..., description="Success message")
|
||||
theme: VendorThemeResponse = Field(..., description="Applied theme")
|
||||
|
||||
|
||||
class ThemePresetListResponse(BaseModel):
|
||||
"""List of available theme presets."""
|
||||
|
||||
presets: list[ThemePresetPreview] = Field(..., description="Available presets")
|
||||
|
||||
|
||||
class ThemeDeleteResponse(BaseModel):
|
||||
"""Response after deleting a theme."""
|
||||
|
||||
message: str = Field(..., description="Success message")
|
||||
@@ -27,7 +27,7 @@ from app.modules.cms.exceptions import (
|
||||
UnsupportedMediaTypeException,
|
||||
MediaFileTooLargeException,
|
||||
)
|
||||
from models.database.media import MediaFile
|
||||
from app.modules.cms.models import MediaFile
|
||||
from app.modules.catalog.models import ProductMedia
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,14 +24,13 @@ from app.exceptions import (
|
||||
ValidationException,
|
||||
ExternalServiceException,
|
||||
)
|
||||
from models.database import (
|
||||
Vendor,
|
||||
from app.modules.tenancy.models import Vendor
|
||||
from app.modules.messaging.models import (
|
||||
VendorEmailSettings,
|
||||
EmailProvider,
|
||||
PREMIUM_EMAIL_PROVIDERS,
|
||||
VendorSubscription,
|
||||
TierCode,
|
||||
)
|
||||
from app.modules.billing.models import VendorSubscription, TierCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ from app.modules.cms.exceptions import (
|
||||
ThemeValidationException,
|
||||
VendorThemeNotFoundException,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.vendor_theme import VendorTheme
|
||||
from models.schema.vendor_theme import ThemePresetPreview, VendorThemeUpdate
|
||||
from app.modules.tenancy.models import Vendor
|
||||
from app.modules.cms.models import VendorTheme
|
||||
from app.modules.cms.schemas.vendor_theme import ThemePresetPreview, VendorThemeUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ function contentPagesManager() {
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('cms');
|
||||
|
||||
contentPagesLog.info('=== CONTENT PAGES MANAGER INITIALIZING ===');
|
||||
|
||||
// Prevent multiple initializations
|
||||
@@ -235,7 +238,7 @@ function contentPagesManager() {
|
||||
|
||||
} catch (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');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -20,24 +20,24 @@
|
||||
<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"/>
|
||||
</svg>
|
||||
{{ _("platform.hero.badge", trial_days=trial_days) }}
|
||||
{{ _("cms.platform.hero.badge", trial_days=trial_days) }}
|
||||
</div>
|
||||
|
||||
{# Headline #}
|
||||
<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>
|
||||
|
||||
{# Subheadline #}
|
||||
<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>
|
||||
|
||||
{# CTA Buttons #}
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<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">
|
||||
{{ _("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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
@@ -47,7 +47,7 @@
|
||||
<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"/>
|
||||
</svg>
|
||||
{{ _("platform.hero.cta_find_shop") }}
|
||||
{{ _("cms.platform.hero.cta_find_shop") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,19 +68,19 @@
|
||||
{# Section Header #}
|
||||
<div class="text-center mb-12">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{# Billing Toggle #}
|
||||
<div class="flex justify-center mt-8">
|
||||
{{ toggle_switch(
|
||||
model='annual',
|
||||
left_label=_("platform.pricing.monthly"),
|
||||
right_label=_("platform.pricing.annual"),
|
||||
right_badge=_("platform.pricing.save_months")
|
||||
left_label=_("cms.platform.pricing.monthly"),
|
||||
right_label=_("cms.platform.pricing.annual"),
|
||||
right_badge=_("cms.platform.pricing.save_months")
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@
|
||||
{% if tier.is_popular %}
|
||||
<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.most_popular") }}
|
||||
{{ _("cms.platform.pricing.most_popular") }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -108,19 +108,19 @@
|
||||
<template x-if="!annual">
|
||||
<div>
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="annual">
|
||||
<div>
|
||||
{% 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-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">
|
||||
{{ tier.price_annual|int }}€ {{ _("platform.pricing.per_year") }}
|
||||
{{ tier.price_annual|int }}€ {{ _("cms.platform.pricing.per_year") }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
{% 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>
|
||||
{# Products #}
|
||||
<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">
|
||||
<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>
|
||||
{% 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>
|
||||
{# Team Members #}
|
||||
<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">
|
||||
<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>
|
||||
{% 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>
|
||||
{# Letzshop Sync - always included #}
|
||||
<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">
|
||||
<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>
|
||||
{{ _("platform.pricing.letzshop_sync") }}
|
||||
{{ _("cms.platform.pricing.letzshop_sync") }}
|
||||
</li>
|
||||
{# 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 %}">
|
||||
@@ -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"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ _("platform.pricing.eu_vat_invoicing") }}
|
||||
{{ _("cms.platform.pricing.eu_vat_invoicing") }}
|
||||
</li>
|
||||
{# 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 %}">
|
||||
@@ -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"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ _("platform.pricing.analytics_dashboard") }}
|
||||
{{ _("cms.platform.pricing.analytics_dashboard") }}
|
||||
</li>
|
||||
{# 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 %}">
|
||||
@@ -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"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ _("platform.pricing.api_access") }}
|
||||
{{ _("cms.platform.pricing.api_access") }}
|
||||
</li>
|
||||
{# 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 %}">
|
||||
@@ -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"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ _("platform.pricing.multi_channel") }}
|
||||
{{ _("cms.platform.pricing.multi_channel") }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -214,14 +214,14 @@
|
||||
{% if tier.is_enterprise %}
|
||||
<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">
|
||||
{{ _("platform.pricing.contact_sales") }}
|
||||
{{ _("cms.platform.pricing.contact_sales") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/signup?tier={{ tier.code }}"
|
||||
:href="'/signup?tier={{ tier.code }}&annual=' + annual"
|
||||
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 %}">
|
||||
{{ _("platform.pricing.start_trial") }}
|
||||
{{ _("cms.platform.pricing.start_trial") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -238,10 +238,10 @@
|
||||
{# Section Header #}
|
||||
<div class="text-center mb-12">
|
||||
<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>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{{ _("platform.addons.subtitle") }}
|
||||
{{ _("cms.platform.addons.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -300,10 +300,10 @@
|
||||
{# Section Header #}
|
||||
<div class="text-center mb-12">
|
||||
<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>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
||||
{{ _("platform.find_shop.subtitle") }}
|
||||
{{ _("cms.platform.find_shop.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
<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"/>
|
||||
</svg>
|
||||
</template>
|
||||
{{ _("platform.find_shop.button") }}
|
||||
{{ _("cms.platform.find_shop.button") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -342,12 +342,12 @@
|
||||
<template x-if="!vendorResult.vendor.is_claimed">
|
||||
<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">
|
||||
{{ _("platform.find_shop.claim_shop") }}
|
||||
{{ _("cms.platform.find_shop.claim_shop") }}
|
||||
</a>
|
||||
</template>
|
||||
<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">
|
||||
{{ _("platform.find_shop.already_claimed") }}
|
||||
{{ _("cms.platform.find_shop.already_claimed") }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -362,7 +362,7 @@
|
||||
|
||||
{# Help Text #}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,14 +374,14 @@
|
||||
<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">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
|
||||
{{ _("platform.cta.title") }}
|
||||
{{ _("cms.platform.cta.title") }}
|
||||
</h2>
|
||||
<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>
|
||||
<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">
|
||||
{{ _("platform.cta.button") }}
|
||||
{{ _("cms.platform.cta.button") }}
|
||||
<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"/>
|
||||
</svg>
|
||||
|
||||
@@ -6,8 +6,8 @@ Dashboard, settings, and profile management.
|
||||
Required for basic operation - cannot be disabled.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
core_module = ModuleDefinition(
|
||||
code="core",
|
||||
@@ -21,6 +21,7 @@ core_module = ModuleDefinition(
|
||||
"settings",
|
||||
"profile",
|
||||
],
|
||||
# Legacy menu_items (IDs only)
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
"dashboard",
|
||||
@@ -35,6 +36,95 @@ core_module = ModuleDefinition(
|
||||
"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"]
|
||||
|
||||
34
app/modules/core/exceptions.py
Normal file
34
app/modules/core/exceptions.py
Normal 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
|
||||
71
app/modules/core/locales/de.json
Normal file
71
app/modules/core/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
71
app/modules/core/locales/fr.json
Normal file
71
app/modules/core/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
71
app/modules/core/locales/lb.json
Normal file
71
app/modules/core/locales/lb.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
app/modules/core/models/__init__.py
Normal file
18
app/modules/core/models/__init__.py
Normal 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",
|
||||
]
|
||||
223
app/modules/core/models/admin_menu_config.py
Normal file
223
app/modules/core/models/admin_menu_config.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# app/modules/core/models/admin_menu_config.py
|
||||
"""
|
||||
Menu visibility configuration for admin and vendor frontends.
|
||||
|
||||
Supports two frontend types:
|
||||
- 'admin': Admin panel menus (for super admins and platform admins)
|
||||
- 'vendor': Vendor dashboard menus (configured per platform)
|
||||
|
||||
Supports two scopes:
|
||||
- Platform-level: Menu config for a platform (platform_id is set)
|
||||
→ For admin frontend: applies to platform admins
|
||||
→ For vendor frontend: applies to all vendors on that platform
|
||||
- User-level: Menu config for a specific super admin (user_id is set)
|
||||
→ Only for admin frontend (super admins configuring their own menu)
|
||||
|
||||
Design:
|
||||
- Opt-out model: All items visible by default, store hidden items
|
||||
- Mandatory items: Some items cannot be hidden (defined per frontend type)
|
||||
- Only stores non-default state (is_visible=False) to keep table small
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
# Import FrontendType and MANDATORY_MENU_ITEMS from the central location
|
||||
from app.modules.enums import FrontendType, MANDATORY_MENU_ITEMS
|
||||
|
||||
|
||||
class AdminMenuConfig(Base, TimestampMixin):
|
||||
"""
|
||||
Menu visibility configuration for admin and vendor frontends.
|
||||
|
||||
Supports two frontend types:
|
||||
- 'admin': Admin panel menus
|
||||
- 'vendor': Vendor dashboard menus
|
||||
|
||||
Supports two scopes:
|
||||
- Platform scope: platform_id is set
|
||||
→ Admin: applies to platform admins of that platform
|
||||
→ Vendor: applies to all vendors on that platform
|
||||
- User scope: user_id is set (admin frontend only)
|
||||
→ Applies to a specific super admin user
|
||||
|
||||
Resolution order for admin frontend:
|
||||
- Platform admins: Check platform config → fall back to default
|
||||
- Super admins: Check user config → fall back to default
|
||||
|
||||
Resolution order for vendor frontend:
|
||||
- Check platform config → fall back to default
|
||||
|
||||
Examples:
|
||||
- Platform "OMS" wants to hide "inventory" from admin panel
|
||||
→ frontend_type='admin', platform_id=1, menu_item_id="inventory", is_visible=False
|
||||
|
||||
- Platform "OMS" wants to hide "letzshop" from vendor dashboard
|
||||
→ frontend_type='vendor', platform_id=1, menu_item_id="letzshop", is_visible=False
|
||||
|
||||
- Super admin "john" wants to hide "code-quality" from their admin panel
|
||||
→ frontend_type='admin', user_id=5, menu_item_id="code-quality", is_visible=False
|
||||
"""
|
||||
|
||||
__tablename__ = "admin_menu_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ========================================================================
|
||||
# Frontend Type
|
||||
# ========================================================================
|
||||
|
||||
frontend_type = Column(
|
||||
Enum(FrontendType, values_callable=lambda obj: [e.value for e in obj]),
|
||||
nullable=False,
|
||||
default=FrontendType.ADMIN,
|
||||
index=True,
|
||||
comment="Which frontend this config applies to (admin or vendor)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Scope: Platform scope OR User scope (for admin frontend only)
|
||||
# ========================================================================
|
||||
|
||||
platform_id = Column(
|
||||
Integer,
|
||||
ForeignKey("platforms.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="Platform scope - applies to users/vendors of this platform",
|
||||
)
|
||||
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="User scope - applies to this specific super admin (admin frontend only)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Menu Item Configuration
|
||||
# ========================================================================
|
||||
|
||||
menu_item_id = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Menu item identifier from registry (e.g., 'products', 'inventory')",
|
||||
)
|
||||
|
||||
is_visible = Column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="Whether this menu item is visible (False = hidden)",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Relationships
|
||||
# ========================================================================
|
||||
|
||||
platform = relationship(
|
||||
"Platform",
|
||||
back_populates="menu_configs",
|
||||
)
|
||||
|
||||
user = relationship(
|
||||
"User",
|
||||
back_populates="menu_configs",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Constraints
|
||||
# ========================================================================
|
||||
|
||||
__table_args__ = (
|
||||
# Unique constraint: one config per frontend+platform+menu_item
|
||||
UniqueConstraint(
|
||||
"frontend_type",
|
||||
"platform_id",
|
||||
"menu_item_id",
|
||||
name="uq_frontend_platform_menu_config",
|
||||
),
|
||||
# Unique constraint: one config per frontend+user+menu_item
|
||||
UniqueConstraint(
|
||||
"frontend_type",
|
||||
"user_id",
|
||||
"menu_item_id",
|
||||
name="uq_frontend_user_menu_config",
|
||||
),
|
||||
# Check: exactly one scope must be set (platform_id XOR user_id)
|
||||
CheckConstraint(
|
||||
"(platform_id IS NOT NULL AND user_id IS NULL) OR "
|
||||
"(platform_id IS NULL AND user_id IS NOT NULL)",
|
||||
name="ck_admin_menu_config_scope",
|
||||
),
|
||||
# Check: user_id scope only allowed for admin frontend
|
||||
CheckConstraint(
|
||||
"(user_id IS NULL) OR (frontend_type = 'admin')",
|
||||
name="ck_user_scope_admin_only",
|
||||
),
|
||||
# Performance indexes
|
||||
Index(
|
||||
"idx_admin_menu_config_frontend_platform",
|
||||
"frontend_type",
|
||||
"platform_id",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_frontend_user",
|
||||
"frontend_type",
|
||||
"user_id",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_platform_visible",
|
||||
"platform_id",
|
||||
"is_visible",
|
||||
),
|
||||
Index(
|
||||
"idx_admin_menu_config_user_visible",
|
||||
"user_id",
|
||||
"is_visible",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Properties
|
||||
# ========================================================================
|
||||
|
||||
@property
|
||||
def scope_type(self) -> str:
|
||||
"""Get the scope type for this config."""
|
||||
if self.platform_id:
|
||||
return "platform"
|
||||
return "user"
|
||||
|
||||
@property
|
||||
def scope_id(self) -> int:
|
||||
"""Get the scope ID (platform_id or user_id)."""
|
||||
return self.platform_id or self.user_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scope = f"platform_id={self.platform_id}" if self.platform_id else f"user_id={self.user_id}"
|
||||
return (
|
||||
f"<AdminMenuConfig("
|
||||
f"frontend_type='{self.frontend_type.value}', "
|
||||
f"{scope}, "
|
||||
f"menu_item_id='{self.menu_item_id}', "
|
||||
f"is_visible={self.is_visible})>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AdminMenuConfig", "FrontendType", "MANDATORY_MENU_ITEMS"]
|
||||
@@ -5,15 +5,18 @@ Core module admin API routes.
|
||||
Aggregates all admin core routes:
|
||||
- /dashboard/* - Admin dashboard and statistics
|
||||
- /settings/* - Platform settings management
|
||||
- /menu-config/* - Menu visibility configuration
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .admin_dashboard import admin_dashboard_router
|
||||
from .admin_settings import admin_settings_router
|
||||
from .admin_menu_config import router as admin_menu_config_router
|
||||
|
||||
admin_router = APIRouter()
|
||||
|
||||
# Aggregate all core admin routes
|
||||
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_menu_config_router, tags=["admin-menu-config"])
|
||||
|
||||
463
app/modules/core/routes/api/admin_menu_config.py
Normal file
463
app/modules/core/routes/api/admin_menu_config.py
Normal file
@@ -0,0 +1,463 @@
|
||||
# app/modules/core/routes/api/admin_menu_config.py
|
||||
"""
|
||||
Admin API endpoints for Platform Menu Configuration.
|
||||
|
||||
Provides menu visibility configuration for admin and vendor frontends:
|
||||
- GET /menu-config/platforms/{platform_id} - Get menu config for a platform
|
||||
- PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform
|
||||
- POST /menu-config/platforms/{platform_id}/reset - Reset to defaults
|
||||
- GET /menu-config/user - Get current user's menu config (super admins)
|
||||
- PUT /menu-config/user - Update current user's menu config (super admins)
|
||||
- GET /menu/admin - Get rendered admin menu for current user
|
||||
- GET /menu/vendor - Get rendered vendor menu for current platform
|
||||
|
||||
All configuration endpoints require super admin access.
|
||||
Menu rendering endpoints require authenticated admin/vendor access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
get_current_admin_from_cookie_or_header,
|
||||
get_current_super_admin,
|
||||
get_db,
|
||||
)
|
||||
from app.modules.core.services.menu_service import MenuItemConfig, menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.enums import FrontendType # noqa: API-007 - Enum for type safety
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/menu-config")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MenuItemResponse(BaseModel):
|
||||
"""Menu item configuration response."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
icon: str
|
||||
url: str
|
||||
section_id: str
|
||||
section_label: str | None = None
|
||||
is_visible: bool = True
|
||||
is_mandatory: bool = False
|
||||
is_super_admin_only: bool = False
|
||||
|
||||
|
||||
class MenuConfigResponse(BaseModel):
|
||||
"""Menu configuration response for a platform or user."""
|
||||
|
||||
frontend_type: str
|
||||
platform_id: int | None = None
|
||||
user_id: int | None = None
|
||||
items: list[MenuItemResponse]
|
||||
total_items: int
|
||||
visible_items: int
|
||||
hidden_items: int
|
||||
|
||||
|
||||
class MenuVisibilityUpdateRequest(BaseModel):
|
||||
"""Request to update menu item visibility."""
|
||||
|
||||
menu_item_id: str = Field(..., description="Menu item ID to update")
|
||||
is_visible: bool = Field(..., description="Whether the item should be visible")
|
||||
|
||||
|
||||
class BulkMenuVisibilityUpdateRequest(BaseModel):
|
||||
"""Request to update multiple menu items at once."""
|
||||
|
||||
visibility: dict[str, bool] = Field(
|
||||
...,
|
||||
description="Map of menu_item_id to is_visible",
|
||||
examples=[{"inventory": False, "orders": True}],
|
||||
)
|
||||
|
||||
|
||||
class MenuSectionResponse(BaseModel):
|
||||
"""Menu section for rendering."""
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
items: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RenderedMenuResponse(BaseModel):
|
||||
"""Rendered menu for frontend."""
|
||||
|
||||
frontend_type: str
|
||||
sections: list[MenuSectionResponse]
|
||||
|
||||
|
||||
class MenuActionResponse(BaseModel):
|
||||
"""Response for menu action operations (reset, show-all, etc.)."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse:
|
||||
"""Convert MenuItemConfig to API response."""
|
||||
return MenuItemResponse(
|
||||
id=item.id,
|
||||
label=item.label,
|
||||
icon=item.icon,
|
||||
url=item.url,
|
||||
section_id=item.section_id,
|
||||
section_label=item.section_label,
|
||||
is_visible=item.is_visible,
|
||||
is_mandatory=item.is_mandatory,
|
||||
is_super_admin_only=item.is_super_admin_only,
|
||||
)
|
||||
|
||||
|
||||
def _build_menu_config_response(
|
||||
items: list[MenuItemConfig],
|
||||
frontend_type: FrontendType,
|
||||
platform_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> MenuConfigResponse:
|
||||
"""Build menu configuration response."""
|
||||
item_responses = [_build_menu_item_response(item) for item in items]
|
||||
visible_count = sum(1 for item in items if item.is_visible)
|
||||
|
||||
return MenuConfigResponse(
|
||||
frontend_type=frontend_type.value,
|
||||
platform_id=platform_id,
|
||||
user_id=user_id,
|
||||
items=item_responses,
|
||||
total_items=len(items),
|
||||
visible_items=visible_count,
|
||||
hidden_items=len(items) - visible_count,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Platform Menu Configuration (Super Admin Only)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/platforms/{platform_id}", response_model=MenuConfigResponse)
|
||||
async def get_platform_menu_config(
|
||||
platform_id: int = Path(..., description="Platform ID"),
|
||||
frontend_type: FrontendType = Query(
|
||||
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get menu configuration for a platform.
|
||||
|
||||
Returns all menu items with their visibility status for the specified
|
||||
platform and frontend type. Super admin only.
|
||||
"""
|
||||
# Verify platform exists
|
||||
platform = platform_service.get_platform_by_id(db, platform_id)
|
||||
|
||||
items = menu_service.get_platform_menu_config(db, frontend_type, platform_id)
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} fetched menu config "
|
||||
f"for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return _build_menu_config_response(items, frontend_type, platform_id=platform_id)
|
||||
|
||||
|
||||
@router.put("/platforms/{platform_id}")
|
||||
async def update_platform_menu_visibility(
|
||||
update_data: MenuVisibilityUpdateRequest,
|
||||
platform_id: int = Path(..., description="Platform ID"),
|
||||
frontend_type: FrontendType = Query(
|
||||
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Update visibility for a single menu item for a platform.
|
||||
|
||||
Super admin only. Cannot hide mandatory items.
|
||||
"""
|
||||
# Verify platform exists
|
||||
platform = platform_service.get_platform_by_id(db, platform_id)
|
||||
|
||||
menu_service.update_menu_visibility(
|
||||
db=db,
|
||||
frontend_type=frontend_type,
|
||||
menu_item_id=update_data.menu_item_id,
|
||||
is_visible=update_data.is_visible,
|
||||
platform_id=platform_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} updated menu visibility: "
|
||||
f"{update_data.menu_item_id}={update_data.is_visible} "
|
||||
f"for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Menu visibility updated"}
|
||||
|
||||
|
||||
@router.put("/platforms/{platform_id}/bulk")
|
||||
async def bulk_update_platform_menu_visibility(
|
||||
update_data: BulkMenuVisibilityUpdateRequest,
|
||||
platform_id: int = Path(..., description="Platform ID"),
|
||||
frontend_type: FrontendType = Query(
|
||||
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Update visibility for multiple menu items at once.
|
||||
|
||||
Super admin only. Skips mandatory items silently.
|
||||
"""
|
||||
# Verify platform exists
|
||||
platform = platform_service.get_platform_by_id(db, platform_id)
|
||||
|
||||
menu_service.bulk_update_menu_visibility(
|
||||
db=db,
|
||||
frontend_type=frontend_type,
|
||||
visibility_map=update_data.visibility,
|
||||
platform_id=platform_id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} bulk updated menu visibility: "
|
||||
f"{len(update_data.visibility)} items for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return {"success": True, "message": f"Updated {len(update_data.visibility)} menu items"}
|
||||
|
||||
|
||||
@router.post("/platforms/{platform_id}/reset")
|
||||
async def reset_platform_menu_config(
|
||||
platform_id: int = Path(..., description="Platform ID"),
|
||||
frontend_type: FrontendType = Query(
|
||||
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Reset menu configuration for a platform to defaults.
|
||||
|
||||
Removes all visibility overrides, making all items visible.
|
||||
Super admin only.
|
||||
"""
|
||||
# Verify platform exists
|
||||
platform = platform_service.get_platform_by_id(db, platform_id)
|
||||
|
||||
menu_service.reset_platform_menu_config(db, frontend_type, platform_id)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} reset menu config "
|
||||
f"for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Menu configuration reset to defaults"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Menu Configuration (Super Admin Only)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/user", response_model=MenuConfigResponse)
|
||||
async def get_user_menu_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get the current super admin's personal menu configuration.
|
||||
|
||||
Only super admins can configure their own admin menu.
|
||||
"""
|
||||
items = menu_service.get_user_menu_config(db, current_user.id)
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
|
||||
)
|
||||
|
||||
return _build_menu_config_response(
|
||||
items, FrontendType.ADMIN, user_id=current_user.id
|
||||
)
|
||||
|
||||
|
||||
@router.put("/user")
|
||||
async def update_user_menu_visibility(
|
||||
update_data: MenuVisibilityUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Update visibility for a single menu item for the current super admin.
|
||||
|
||||
Super admin only. Cannot hide mandatory items.
|
||||
"""
|
||||
menu_service.update_menu_visibility(
|
||||
db=db,
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
menu_item_id=update_data.menu_item_id,
|
||||
is_visible=update_data.is_visible,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: "
|
||||
f"{update_data.menu_item_id}={update_data.is_visible}"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Menu visibility updated"}
|
||||
|
||||
|
||||
@router.post("/user/reset", response_model=MenuActionResponse)
|
||||
async def reset_user_menu_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Reset the current super admin's menu configuration (hide all except mandatory).
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
menu_service.reset_user_menu_config(db, current_user.id)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)"
|
||||
)
|
||||
|
||||
return MenuActionResponse(success=True, message="Menu configuration reset - all items hidden")
|
||||
|
||||
|
||||
@router.post("/user/show-all", response_model=MenuActionResponse)
|
||||
async def show_all_user_menu_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Show all menu items for the current super admin.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
menu_service.show_all_user_menu_config(db, current_user.id)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items"
|
||||
)
|
||||
|
||||
return MenuActionResponse(success=True, message="All menu items are now visible")
|
||||
|
||||
|
||||
@router.post("/platforms/{platform_id}/show-all")
|
||||
async def show_all_platform_menu_config(
|
||||
platform_id: int = Path(..., description="Platform ID"),
|
||||
frontend_type: FrontendType = Query(
|
||||
FrontendType.ADMIN, description="Frontend type (admin or vendor)"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Show all menu items for a platform.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
# Verify platform exists
|
||||
platform = platform_service.get_platform_by_id(db, platform_id)
|
||||
|
||||
menu_service.show_all_platform_menu_config(db, frontend_type, platform_id)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items "
|
||||
f"for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "All menu items are now visible"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Menu Rendering (For Sidebar)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/render/admin", response_model=RenderedMenuResponse)
|
||||
async def get_rendered_admin_menu(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_admin_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get the rendered admin menu for the current user.
|
||||
|
||||
Returns the filtered menu structure based on:
|
||||
- Super admins: user-level config
|
||||
- Platform admins: platform-level config
|
||||
|
||||
Used by the frontend to render the sidebar.
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
# Super admin: use user-level config
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
user_id=current_user.id,
|
||||
is_super_admin=True,
|
||||
)
|
||||
else:
|
||||
# Platform admin: use platform-level config
|
||||
# Get the selected platform from the JWT token
|
||||
platform_id = getattr(current_user, "token_platform_id", None)
|
||||
|
||||
# Fallback to first platform if no platform in token (shouldn't happen)
|
||||
if platform_id is None and current_user.admin_platforms:
|
||||
platform_id = current_user.admin_platforms[0].id
|
||||
logger.warning(
|
||||
f"[MENU_CONFIG] No platform_id in token for {current_user.email}, "
|
||||
f"falling back to first platform: {platform_id}"
|
||||
)
|
||||
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
platform_id=platform_id,
|
||||
is_super_admin=False,
|
||||
)
|
||||
|
||||
sections = [
|
||||
MenuSectionResponse(
|
||||
id=section["id"],
|
||||
label=section.get("label"),
|
||||
items=section["items"],
|
||||
)
|
||||
for section in menu.get("sections", [])
|
||||
]
|
||||
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.ADMIN.value,
|
||||
sections=sections,
|
||||
)
|
||||
@@ -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.core.services.admin_settings_service import admin_settings_service
|
||||
from models.schema.auth import UserContext
|
||||
from models.schema.admin import (
|
||||
from app.modules.tenancy.schemas.admin import (
|
||||
AdminSettingCreate,
|
||||
AdminSettingDefaultResponse,
|
||||
AdminSettingListResponse,
|
||||
@@ -528,7 +528,7 @@ def update_email_settings(
|
||||
Settings are stored in the database and override .env values.
|
||||
Only non-null values are updated.
|
||||
"""
|
||||
from models.schema.admin import AdminSettingCreate
|
||||
from app.modules.tenancy.schemas.admin import AdminSettingCreate
|
||||
|
||||
updated_keys = []
|
||||
|
||||
|
||||
@@ -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.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -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.modules.core.utils.page_context import get_vendor_context
|
||||
from app.templates_config import templates
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
0
app/modules/core/schemas/__init__.py
Normal file
0
app/modules/core/schemas/__init__.py
Normal 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.image_service import ImageService, image_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 (
|
||||
PlatformSettingsService,
|
||||
platform_settings_service,
|
||||
@@ -34,10 +40,15 @@ __all__ = [
|
||||
# Auth
|
||||
"AuthService",
|
||||
"auth_service",
|
||||
# Menu
|
||||
# Menu (legacy)
|
||||
"MenuService",
|
||||
"MenuItemConfig",
|
||||
"menu_service",
|
||||
# Menu Discovery (module-driven)
|
||||
"MenuDiscoveryService",
|
||||
"DiscoveredMenuItem",
|
||||
"DiscoveredMenuSection",
|
||||
"menu_discovery_service",
|
||||
# Image
|
||||
"ImageService",
|
||||
"image_service",
|
||||
|
||||
@@ -21,8 +21,8 @@ from app.exceptions import (
|
||||
ValidationException,
|
||||
)
|
||||
from app.modules.tenancy.exceptions import AdminOperationException
|
||||
from models.database.admin import AdminSetting
|
||||
from models.schema.admin import (
|
||||
from app.modules.tenancy.models import AdminSetting
|
||||
from app.modules.tenancy.schemas.admin import (
|
||||
AdminSettingCreate,
|
||||
AdminSettingResponse,
|
||||
AdminSettingUpdate,
|
||||
|
||||
@@ -19,8 +19,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.tenancy.exceptions import InvalidCredentialsException, UserNotActiveException
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor, VendorUser
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor, VendorUser
|
||||
from models.schema.auth import UserLogin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
446
app/modules/core/services/menu_discovery_service.py
Normal file
446
app/modules/core/services/menu_discovery_service.py
Normal 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",
|
||||
]
|
||||
@@ -42,11 +42,9 @@ from app.config.menu_registry import (
|
||||
is_super_admin_only_item,
|
||||
)
|
||||
from app.modules.service import module_service
|
||||
from models.database.admin_menu_config import (
|
||||
AdminMenuConfig,
|
||||
FrontendType,
|
||||
MANDATORY_MENU_ITEMS,
|
||||
)
|
||||
from app.modules.core.models import AdminMenuConfig, MANDATORY_MENU_ITEMS
|
||||
from app.modules.core.services.menu_discovery_service import menu_discovery_service
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -236,10 +234,13 @@ class MenuService:
|
||||
platform_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
is_super_admin: bool = False,
|
||||
vendor_code: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
|
||||
Uses MenuDiscoveryService to aggregate menus from all enabled modules.
|
||||
|
||||
Filters by:
|
||||
1. Module enablement (items from disabled modules are removed)
|
||||
2. Visibility configuration
|
||||
@@ -251,40 +252,23 @@ class MenuService:
|
||||
platform_id: Platform ID (for platform admins and vendors)
|
||||
user_id: User ID (for super admins only)
|
||||
is_super_admin: Whether user is super admin (affects admin-only sections)
|
||||
vendor_code: Vendor code for URL placeholder replacement (vendor frontend)
|
||||
|
||||
Returns:
|
||||
Filtered menu structure ready for rendering
|
||||
"""
|
||||
registry = (
|
||||
ADMIN_MENU_REGISTRY if frontend_type == FrontendType.ADMIN else VENDOR_MENU_REGISTRY
|
||||
# Use the module-driven discovery service to get filtered menu
|
||||
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)
|
||||
|
||||
# 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
|
||||
# Convert to legacy format for backwards compatibility with existing templates
|
||||
return menu_discovery_service.menu_to_legacy_format(sections)
|
||||
|
||||
# =========================================================================
|
||||
# Menu Configuration (Super Admin)
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from models.database.admin import AdminSetting
|
||||
from app.modules.tenancy.models import AdminSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ function adminDashboard() {
|
||||
* Initialize dashboard
|
||||
*/
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('core');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._dashboardInitialized) {
|
||||
dashLog.warn('Dashboard already initialized, skipping...');
|
||||
@@ -79,7 +82,7 @@ function adminDashboard() {
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Dashboard Load');
|
||||
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 {
|
||||
this.loading = false;
|
||||
@@ -182,7 +185,7 @@ function adminDashboard() {
|
||||
async refresh() {
|
||||
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
|
||||
await this.loadDashboard();
|
||||
Utils.showToast('Dashboard refreshed', 'success');
|
||||
Utils.showToast(I18n.t('core.messages.dashboard_refreshed'), 'success');
|
||||
dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,8 +14,8 @@ from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.modules.core.services.platform_settings_service import platform_settings_service
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ Defines the customers module including its features, menu items,
|
||||
route configurations, and self-contained module settings.
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
def _get_admin_router():
|
||||
@@ -46,6 +46,43 @@ customers_module = ModuleDefinition(
|
||||
"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
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_db, require_menu_access
|
||||
from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.templates_config import templates
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from models.database.user import User
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
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.templates_config import templates
|
||||
from models.database.user import User
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.customers.exceptions import CustomerNotFoundException
|
||||
from app.modules.customers.models import Customer
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.modules.tenancy.exceptions import VendorNotActiveException, VendorNotFo
|
||||
from app.modules.core.services.auth_service import AuthService
|
||||
from app.modules.customers.models import Customer, PasswordResetToken
|
||||
from app.modules.customers.schemas import CustomerRegister, CustomerUpdate
|
||||
from models.database.vendor import Vendor
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ function adminCustomers() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('customers');
|
||||
|
||||
customersLog.debug('Customers page initialized');
|
||||
|
||||
// Load platform settings for rows per page
|
||||
@@ -369,7 +372,7 @@ function adminCustomers() {
|
||||
customersLog.info(response.message);
|
||||
} catch (error) {
|
||||
customersLog.error('Failed to toggle status:', error);
|
||||
Utils.showToast(error.message || 'Failed to toggle customer status', 'error');
|
||||
Utils.showToast(error.message || I18n.t('customers.messages.failed_to_toggle_customer_status'), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ function vendorCustomers() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('customers');
|
||||
|
||||
vendorCustomersLog.info('Customers init() called');
|
||||
|
||||
// Guard against multiple initialization
|
||||
@@ -218,7 +221,7 @@ function vendorCustomers() {
|
||||
vendorCustomersLog.info('Loaded customer details:', customer.id);
|
||||
} catch (error) {
|
||||
vendorCustomersLog.error('Failed to load customer details:', error);
|
||||
Utils.showToast(error.message || 'Failed to load customer details', 'error');
|
||||
Utils.showToast(error.message || I18n.t('customers.messages.failed_to_load_customer_details'), 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -237,7 +240,7 @@ function vendorCustomers() {
|
||||
vendorCustomersLog.info('Loaded customer orders:', customer.id, this.customerOrders.length);
|
||||
} catch (error) {
|
||||
vendorCustomersLog.error('Failed to load customer orders:', error);
|
||||
Utils.showToast(error.message || 'Failed to load customer orders', 'error');
|
||||
Utils.showToast(error.message || I18n.t('customers.messages.failed_to_load_customer_orders'), 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ Dev-Tools is an internal module providing:
|
||||
- Icon browser
|
||||
"""
|
||||
|
||||
from app.modules.base import ModuleDefinition
|
||||
from models.database.admin_menu_config import FrontendType
|
||||
from app.modules.base import MenuItemDefinition, MenuSectionDefinition, ModuleDefinition
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
# Dev-Tools module definition
|
||||
@@ -46,6 +46,34 @@ dev_tools_module = ModuleDefinition(
|
||||
],
|
||||
FrontendType.VENDOR: [], # No vendor menu items - internal module
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
FrontendType.ADMIN: [
|
||||
MenuSectionDefinition(
|
||||
id="devTools",
|
||||
label_key="dev_tools.menu.developer_tools",
|
||||
icon="view-grid",
|
||||
order=85,
|
||||
is_super_admin_only=True,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="components",
|
||||
label_key="dev_tools.menu.components",
|
||||
icon="view-grid",
|
||||
route="/admin/components",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="icons",
|
||||
label_key="dev_tools.menu.icons",
|
||||
icon="photograph",
|
||||
route="/admin/icons",
|
||||
order=20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_internal=True, # Internal module - admin-only, not customer-facing
|
||||
# =========================================================================
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
{}
|
||||
{
|
||||
"messages": {
|
||||
"test_run_started": "Test run started...",
|
||||
"failed_to_copy_code": "Failed to copy code",
|
||||
"failed_to_copy_name": "Failed to copy name",
|
||||
"added_to_cart": "Added to cart!",
|
||||
"filters_applied": "Filters applied!",
|
||||
"review_submitted_successfully": "Review submitted successfully!",
|
||||
"thanks_for_your_feedback": "Thanks for your feedback!",
|
||||
"code_copied_to_clipboard": "Code copied to clipboard!"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user