Compare commits
80 Commits
66b77e747d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ade1b9354 | |||
| b5bb9415f6 | |||
| bb3d6f0012 | |||
| c92fe1261b | |||
| ca152cd544 | |||
| 914967edcc | |||
| 64fe58c171 | |||
| 3044490a3e | |||
| adc36246b8 | |||
| dd9dc04328 | |||
| 4a60d75a13 | |||
| e98eddc168 | |||
| 8cd09f3f89 | |||
| 4c1608f78a | |||
| 24219e4d9a | |||
| fde58bea06 | |||
| 52b78ce346 | |||
| f804ff8442 | |||
| d9abb275a5 | |||
| 4b56eb7ab1 | |||
| 27ac7f3e28 | |||
| dfd42c1b10 | |||
| 297b8a8d5a | |||
| 91fb4b0757 | |||
| f4386e97ee | |||
| e8c9fc7e7d | |||
| d591200df8 | |||
| 83af32eb88 | |||
| 2a49e3d30f | |||
| 6e40e16017 | |||
| dd09bcaeec | |||
| 013eafd775 | |||
| 07cd66a0e3 | |||
| 73d453d78a | |||
| d4e9fed719 | |||
| 3e93f64c6b | |||
| 377d2d3ae8 | |||
| b51f9e8e30 | |||
| d380437594 | |||
| cff0af31be | |||
| e492e5f71c | |||
| 9a5b7dd061 | |||
| b3051b423a | |||
| bc951a36d9 | |||
| 2e043260eb | |||
| 1828ac85eb | |||
| 50a4fc38a7 | |||
| 30f3dae5a3 | |||
| 4c750f0268 | |||
| 59b0d8977a | |||
| 2bc03ed97c | |||
| 91963f3b87 | |||
| 3ae0b579d3 | |||
| 972ee1e5d0 | |||
| 70f2803dd3 | |||
| a247622d23 | |||
| 50d50fcbd0 | |||
| b306a5e8f4 | |||
| 28b08580c8 | |||
| 754bfca87d | |||
| 1decb4572c | |||
| d685341b04 | |||
| 0c6d8409c7 | |||
| f81851445e | |||
| 4748368809 | |||
| f310363f7c | |||
| 95f0eac079 | |||
| 11dcfdad73 | |||
| 01f7add8dd | |||
| 0d1007282a | |||
| 2a15c14ee8 | |||
| bc5e227d81 | |||
| 8a70259445 | |||
| 823935c016 | |||
| dab5560de8 | |||
| 157b4c6ec3 | |||
| 211c46ebbc | |||
| d81e9a3fa4 | |||
| fd0de714a4 | |||
| c6b155520c |
10
.env.example
10
.env.example
@@ -72,6 +72,11 @@ LOG_FILE=logs/app.log
|
||||
# Your main platform domain
|
||||
MAIN_DOMAIN=wizard.lu
|
||||
|
||||
# Full base URL for outbound links (emails, billing redirects, etc.)
|
||||
# Must include protocol and port if non-standard
|
||||
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||
APP_BASE_URL=http://localhost:8000
|
||||
|
||||
# Custom domain features
|
||||
# Enable/disable custom domains
|
||||
ALLOW_CUSTOM_DOMAINS=True
|
||||
@@ -223,7 +228,10 @@ R2_BACKUP_BUCKET=orion-backups
|
||||
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
|
||||
# Get Issuer ID from https://pay.google.com/business/console
|
||||
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
|
||||
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
|
||||
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
|
||||
# Path is validated at startup — file must exist and be readable, otherwise
|
||||
# the app fails fast at import time.
|
||||
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
|
||||
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
|
||||
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
|
||||
|
||||
|
||||
@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
|
||||
except Exception:
|
||||
# Invalid token, store mismatch, or other error
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STOREFRONT MODULE GATING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_storefront_module_gate(module_code: str):
|
||||
"""
|
||||
Create a FastAPI dependency that gates storefront routes by module enablement.
|
||||
|
||||
Used by main.py at route registration time: each non-core module's storefront
|
||||
router gets this dependency injected automatically. The framework already knows
|
||||
which module owns each route via RouteInfo.module_code — no hardcoded path map.
|
||||
|
||||
Args:
|
||||
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
|
||||
|
||||
Returns:
|
||||
A FastAPI dependency function
|
||||
"""
|
||||
|
||||
async def _check_module_enabled(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
from app.modules.service import module_service
|
||||
|
||||
platform = getattr(request.state, "platform", None)
|
||||
if not platform:
|
||||
return # No platform context — let other middleware handle it
|
||||
|
||||
if not module_service.is_module_enabled(db, platform.id, module_code):
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
return _check_module_enabled
|
||||
|
||||
@@ -98,6 +98,11 @@ class Settings(BaseSettings):
|
||||
# =============================================================================
|
||||
main_domain: str = "wizard.lu"
|
||||
|
||||
# Full base URL for outbound links (emails, redirects, etc.)
|
||||
# Must include protocol and port if non-standard.
|
||||
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
|
||||
app_base_url: str = "http://localhost:8000"
|
||||
|
||||
# Custom domain features
|
||||
allow_custom_domains: bool = True
|
||||
require_domain_verification: bool = True
|
||||
|
||||
54
app/core/preview_token.py
Normal file
54
app/core/preview_token.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# app/core/preview_token.py
|
||||
"""
|
||||
Signed preview tokens for POC site previews.
|
||||
|
||||
Generates time-limited JWT tokens that allow viewing storefront pages
|
||||
for stores without active subscriptions (POC sites). The token is
|
||||
validated by StorefrontAccessMiddleware to bypass the subscription gate.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREVIEW_TOKEN_HOURS = 24
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
|
||||
"""Create a signed preview token for a POC site.
|
||||
|
||||
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
|
||||
to a specific store_id. Shareable with clients for preview access.
|
||||
"""
|
||||
payload = {
|
||||
"sub": f"preview:{store_id}",
|
||||
"store_id": store_id,
|
||||
"store_code": store_code,
|
||||
"site_id": site_id,
|
||||
"preview": True,
|
||||
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_preview_token(token: str, store_id: int) -> bool:
|
||||
"""Verify a preview token is valid and matches the store.
|
||||
|
||||
Returns True if:
|
||||
- Token signature is valid
|
||||
- Token has not expired
|
||||
- Token has preview=True claim
|
||||
- Token store_id matches the requested store
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
|
||||
return payload.get("preview") is True and payload.get("store_id") == store_id
|
||||
except JWTError:
|
||||
return False
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('location-marker', 'w-6 h-6')"></span>
|
||||
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>
|
||||
|
||||
@@ -95,6 +95,7 @@ class MenuItemDefinition:
|
||||
requires_permission: str | None = None
|
||||
badge_source: str | None = None
|
||||
is_super_admin_only: bool = False
|
||||
header_template: str | None = None # Optional partial for custom header rendering
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -144,7 +144,7 @@ def purchase_addon(
|
||||
store = billing_service.get_store(db, store_id)
|
||||
|
||||
# Build URLs
|
||||
base_url = f"https://{settings.main_domain}"
|
||||
base_url = settings.app_base_url.rstrip("/")
|
||||
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
|
||||
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def create_checkout_session(
|
||||
|
||||
store_code = subscription_service.get_store_code(db, store_id)
|
||||
|
||||
base_url = f"https://{settings.main_domain}"
|
||||
base_url = settings.app_base_url.rstrip("/")
|
||||
success_url = f"{base_url}/store/{store_code}/billing?success=true"
|
||||
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
|
||||
|
||||
@@ -87,7 +87,7 @@ def create_portal_session(
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
store_code = subscription_service.get_store_code(db, store_id)
|
||||
return_url = f"https://{settings.main_domain}/store/{store_code}/billing"
|
||||
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
|
||||
|
||||
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)
|
||||
|
||||
|
||||
@@ -617,7 +617,7 @@ class SignupService:
|
||||
|
||||
# Build login URL
|
||||
login_url = (
|
||||
f"https://{settings.main_domain}"
|
||||
f"{settings.app_base_url.rstrip('/')}"
|
||||
f"/store/{store.store_code}/dashboard"
|
||||
)
|
||||
|
||||
|
||||
@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="cart",
|
||||
label_key="storefront.actions.cart",
|
||||
label_key="cart.storefront.actions.cart",
|
||||
icon="shopping-cart",
|
||||
route="cart",
|
||||
order=20,
|
||||
header_template="cart/storefront/partials/header-cart.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Warenkörbe der Kunden anzeigen",
|
||||
"manage": "Warenkörbe verwalten",
|
||||
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Warenkorb"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,53 @@
|
||||
{
|
||||
"title": "Shopping Cart",
|
||||
"description": "Shopping cart management for customers",
|
||||
"cart": {
|
||||
"title": "Your Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"empty_subtitle": "Add items to start shopping",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"proceed_to_checkout": "Proceed to Checkout"
|
||||
},
|
||||
"item": {
|
||||
"product": "Product",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"total": "Total",
|
||||
"remove": "Remove",
|
||||
"update": "Update"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Order Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"estimated_shipping": "Calculated at checkout",
|
||||
"tax": "Tax",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Invalid quantity",
|
||||
"min_quantity": "Minimum quantity is {min}",
|
||||
"max_quantity": "Maximum quantity is {max}",
|
||||
"insufficient_inventory": "Only {available} available"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "View Carts",
|
||||
"view_desc": "View customer shopping carts",
|
||||
"manage": "Manage Carts",
|
||||
"manage_desc": "Modify and manage customer carts"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Item added to cart",
|
||||
"item_updated": "Cart updated",
|
||||
"item_removed": "Item removed from cart",
|
||||
"cart_cleared": "Cart cleared",
|
||||
"product_not_available": "Product not available",
|
||||
"error_adding": "Error adding item to cart",
|
||||
"error_updating": "Error updating cart"
|
||||
}
|
||||
"title": "Shopping Cart",
|
||||
"description": "Shopping cart management for customers",
|
||||
"cart": {
|
||||
"title": "Your Cart",
|
||||
"empty": "Your cart is empty",
|
||||
"empty_subtitle": "Add items to start shopping",
|
||||
"continue_shopping": "Continue Shopping",
|
||||
"proceed_to_checkout": "Proceed to Checkout"
|
||||
},
|
||||
"item": {
|
||||
"product": "Product",
|
||||
"quantity": "Quantity",
|
||||
"price": "Price",
|
||||
"total": "Total",
|
||||
"remove": "Remove",
|
||||
"update": "Update"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Order Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"estimated_shipping": "Calculated at checkout",
|
||||
"tax": "Tax",
|
||||
"total": "Total"
|
||||
},
|
||||
"validation": {
|
||||
"invalid_quantity": "Invalid quantity",
|
||||
"min_quantity": "Minimum quantity is {min}",
|
||||
"max_quantity": "Maximum quantity is {max}",
|
||||
"insufficient_inventory": "Only {available} available"
|
||||
},
|
||||
"permissions": {
|
||||
"view": "View Carts",
|
||||
"view_desc": "View customer shopping carts",
|
||||
"manage": "Manage Carts",
|
||||
"manage_desc": "Modify and manage customer carts"
|
||||
},
|
||||
"messages": {
|
||||
"item_added": "Item added to cart",
|
||||
"item_updated": "Cart updated",
|
||||
"item_removed": "Item removed from cart",
|
||||
"cart_cleared": "Cart cleared",
|
||||
"product_not_available": "Product not available",
|
||||
"error_adding": "Error adding item to cart",
|
||||
"error_updating": "Error updating cart"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Cart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Voir les paniers des clients",
|
||||
"manage": "Gérer les paniers",
|
||||
"manage_desc": "Modifier et gérer les paniers des clients"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Panier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,10 @@
|
||||
"view_desc": "Clientekuerf kucken",
|
||||
"manage": "Kuerf verwalten",
|
||||
"manage_desc": "Clientekuerf änneren a verwalten"
|
||||
},
|
||||
"storefront": {
|
||||
"actions": {
|
||||
"cart": "Kuerf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{# cart/storefront/partials/header-cart.html #}
|
||||
{# Cart icon with badge for storefront header — provided by cart module #}
|
||||
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
<span x-show="cartCount > 0"
|
||||
x-text="cartCount"
|
||||
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
|
||||
style="background-color: var(--color-accent)">
|
||||
</span>
|
||||
</a>
|
||||
@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="products",
|
||||
label_key="storefront.nav.products",
|
||||
label_key="catalog.storefront.nav.products",
|
||||
icon="shopping-bag",
|
||||
route="products",
|
||||
order=10,
|
||||
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="search",
|
||||
label_key="storefront.actions.search",
|
||||
label_key="catalog.storefront.actions.search",
|
||||
icon="search",
|
||||
route="",
|
||||
order=10,
|
||||
header_template="catalog/storefront/partials/header-search.html",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Massenimport von Produkten",
|
||||
"products_export": "Produkte exportieren",
|
||||
"products_export_desc": "Produktdaten exportieren"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produkte"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,5 +107,13 @@
|
||||
"menu": {
|
||||
"products_inventory": "Products & Inventory",
|
||||
"all_products": "All Products"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Products"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Importation en masse de produits",
|
||||
"products_export": "Exporter les produits",
|
||||
"products_export_desc": "Exporter les données produits"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produits"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Rechercher"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,13 @@
|
||||
"products_import_desc": "Massenimport vu Produiten",
|
||||
"products_export": "Produiten exportéieren",
|
||||
"products_export_desc": "Produitdaten exportéieren"
|
||||
},
|
||||
"storefront": {
|
||||
"nav": {
|
||||
"products": "Produkter"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Sichen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,10 @@ router = APIRouter()
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Render shop homepage / product catalog.
|
||||
Render product catalog listing.
|
||||
Shows featured products and categories.
|
||||
"""
|
||||
logger.debug(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{# catalog/storefront/partials/header-search.html #}
|
||||
{# Search button for storefront header — provided by catalog module #}
|
||||
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "Mein Konto",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"explore": "Entdecken",
|
||||
"quick_links": "Schnellzugriff",
|
||||
"information": "Informationen",
|
||||
"about": "Über uns",
|
||||
"contact": "Kontakt",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
"content_pages": "Content Pages",
|
||||
"store_themes": "Store Themes",
|
||||
"media_library": "Media Library"
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "My Account",
|
||||
"learn_more": "Learn More",
|
||||
"explore": "Explore",
|
||||
"quick_links": "Quick Links",
|
||||
"information": "Information",
|
||||
"about": "About Us",
|
||||
"contact": "Contact",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "Mon Compte",
|
||||
"learn_more": "En savoir plus",
|
||||
"explore": "Découvrir",
|
||||
"quick_links": "Liens rapides",
|
||||
"information": "Informations",
|
||||
"about": "À propos",
|
||||
"contact": "Contact",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,5 +388,15 @@
|
||||
},
|
||||
"confirmations": {
|
||||
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
|
||||
},
|
||||
"storefront": {
|
||||
"my_account": "Mäi Kont",
|
||||
"learn_more": "Méi gewuer ginn",
|
||||
"explore": "Entdecken",
|
||||
"quick_links": "Schnellzougrëff",
|
||||
"information": "Informatiounen",
|
||||
"about": "Iwwer eis",
|
||||
"contact": "Kontakt",
|
||||
"faq": "FAQ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add meta_description_translations and drop meta_keywords from content_pages
|
||||
|
||||
Revision ID: cms_003
|
||||
Revises: cms_002
|
||||
Create Date: 2026-04-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "cms_003"
|
||||
down_revision = "cms_002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"content_pages",
|
||||
sa.Column(
|
||||
"meta_description_translations",
|
||||
sa.JSON(),
|
||||
nullable=True,
|
||||
comment="Language-keyed meta description dict for multi-language SEO",
|
||||
),
|
||||
)
|
||||
op.drop_column("content_pages", "meta_keywords")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"content_pages",
|
||||
sa.Column("meta_keywords", sa.String(300), nullable=True),
|
||||
)
|
||||
op.drop_column("content_pages", "meta_description_translations")
|
||||
@@ -135,7 +135,12 @@ class ContentPage(Base):
|
||||
|
||||
# SEO
|
||||
meta_description = Column(String(300), nullable=True)
|
||||
meta_keywords = Column(String(300), nullable=True)
|
||||
meta_description_translations = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="Language-keyed meta description dict for multi-language SEO",
|
||||
)
|
||||
|
||||
# Publishing
|
||||
is_published = Column(Boolean, default=False, nullable=False)
|
||||
@@ -230,6 +235,16 @@ class ContentPage(Base):
|
||||
)
|
||||
return self.content
|
||||
|
||||
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
|
||||
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
|
||||
if self.meta_description_translations:
|
||||
return (
|
||||
self.meta_description_translations.get(lang)
|
||||
or self.meta_description_translations.get(default_lang)
|
||||
or self.meta_description or ""
|
||||
)
|
||||
return self.meta_description or ""
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
@@ -248,7 +263,7 @@ class ContentPage(Base):
|
||||
"template": self.template,
|
||||
"sections": self.sections,
|
||||
"meta_description": self.meta_description,
|
||||
"meta_keywords": self.meta_keywords,
|
||||
"meta_description_translations": self.meta_description_translations,
|
||||
"is_published": self.is_published,
|
||||
"published_at": (
|
||||
self.published_at.isoformat() if self.published_at else None
|
||||
|
||||
@@ -73,7 +73,7 @@ def create_platform_page(
|
||||
content_format=page_data.content_format,
|
||||
template=page_data.template,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
meta_description_translations=page_data.meta_description_translations,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
@@ -117,7 +117,7 @@ def create_store_page(
|
||||
content_format=page_data.content_format,
|
||||
template=page_data.template,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
meta_description_translations=page_data.meta_description_translations,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
@@ -177,11 +177,13 @@ def update_page(
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=page_data.title,
|
||||
title_translations=page_data.title_translations,
|
||||
content=page_data.content,
|
||||
content_translations=page_data.content_translations,
|
||||
content_format=page_data.content_format,
|
||||
template=page_data.template,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
meta_description_translations=page_data.meta_description_translations,
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
|
||||
@@ -207,7 +207,7 @@ def create_store_page(
|
||||
store_id=current_user.token_store_id,
|
||||
content_format=page_data.content_format,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
meta_description_translations=getattr(page_data, "meta_description_translations", None),
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
@@ -241,7 +241,7 @@ def update_store_page(
|
||||
content=page_data.content,
|
||||
content_format=page_data.content_format,
|
||||
meta_description=page_data.meta_description,
|
||||
meta_keywords=page_data.meta_keywords,
|
||||
meta_description_translations=getattr(page_data, "meta_description_translations", None),
|
||||
is_published=page_data.is_published,
|
||||
show_in_footer=page_data.show_in_footer,
|
||||
show_in_header=page_data.show_in_header,
|
||||
|
||||
@@ -28,6 +28,79 @@ ROUTE_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STOREFRONT HOMEPAGE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def storefront_homepage(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Storefront homepage handler.
|
||||
|
||||
Looks for a CMS page with slug="home" (store override → store default),
|
||||
and renders the appropriate landing template. Falls back to the default
|
||||
landing template when no CMS homepage exists.
|
||||
"""
|
||||
store = getattr(request.state, "store", None)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
store_id = store.id if store else None
|
||||
if not platform:
|
||||
raise HTTPException(status_code=400, detail="Platform context required")
|
||||
|
||||
# Try to load a homepage from CMS (store override → store default)
|
||||
page = content_page_service.get_page_for_store(
|
||||
db,
|
||||
platform_id=platform.id,
|
||||
slug="home",
|
||||
store_id=store_id,
|
||||
include_unpublished=False,
|
||||
)
|
||||
|
||||
# Resolve placeholders for store default pages (title, content, sections)
|
||||
page_content = None
|
||||
page_title = None
|
||||
page_sections = None
|
||||
if page:
|
||||
page_content = page.content
|
||||
page_title = page.title
|
||||
page_sections = page.sections
|
||||
if page.is_store_default and store:
|
||||
page_content = content_page_service.resolve_placeholders(
|
||||
page.content, store
|
||||
)
|
||||
page_title = content_page_service.resolve_placeholders(
|
||||
page.title, store
|
||||
)
|
||||
if page_sections:
|
||||
page_sections = content_page_service.resolve_placeholders_deep(
|
||||
page_sections, store
|
||||
)
|
||||
|
||||
context = get_storefront_context(request, db=db, page=page)
|
||||
if page_content:
|
||||
context["page_content"] = page_content
|
||||
if page_title:
|
||||
context["page_title"] = page_title
|
||||
if page_sections:
|
||||
context["page_sections"] = page_sections
|
||||
|
||||
# Select template based on page.template field (or default)
|
||||
template_map = {
|
||||
"full": "cms/storefront/landing-full.html",
|
||||
"modern": "cms/storefront/landing-modern.html",
|
||||
"minimal": "cms/storefront/landing-minimal.html",
|
||||
}
|
||||
template_name = "cms/storefront/landing-default.html"
|
||||
if page and page.template:
|
||||
template_name = template_map.get(page.template, template_name)
|
||||
|
||||
return templates.TemplateResponse(template_name, context)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DYNAMIC CONTENT PAGES (CMS)
|
||||
# ============================================================================
|
||||
@@ -103,14 +176,25 @@ async def generic_content_page(
|
||||
|
||||
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
||||
page_content = page.content
|
||||
page_title = page.title
|
||||
if page.is_store_default and store:
|
||||
page_content = content_page_service.resolve_placeholders(page.content, store)
|
||||
page_title = content_page_service.resolve_placeholders(page.title, store)
|
||||
|
||||
context = get_storefront_context(request, db=db, page=page)
|
||||
context["page_title"] = page_title
|
||||
context["page_content"] = page_content
|
||||
|
||||
# Select template based on page.template field
|
||||
template_map = {
|
||||
"full": "cms/storefront/landing-full.html",
|
||||
"modern": "cms/storefront/landing-modern.html",
|
||||
"minimal": "cms/storefront/landing-minimal.html",
|
||||
}
|
||||
template_name = template_map.get(page.template, "cms/storefront/content-page.html")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"cms/storefront/content-page.html",
|
||||
template_name,
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
|
||||
description="URL-safe identifier (about, faq, contact, etc.)",
|
||||
)
|
||||
title: str = Field(..., max_length=200, description="Page title")
|
||||
title_translations: dict[str, str] | None = Field(
|
||||
None, description="Title translations keyed by language code"
|
||||
)
|
||||
content: str = Field(..., description="HTML or Markdown content")
|
||||
content_translations: dict[str, str] | None = Field(
|
||||
None, description="Content translations keyed by language code"
|
||||
)
|
||||
content_format: str = Field(
|
||||
default="html", description="Content format: html or markdown"
|
||||
)
|
||||
template: str = Field(
|
||||
default="default",
|
||||
max_length=50,
|
||||
description="Template name (default, minimal, modern)",
|
||||
description="Template name (default, minimal, modern, full)",
|
||||
)
|
||||
meta_description: str | None = Field(
|
||||
None, max_length=300, description="SEO meta description"
|
||||
)
|
||||
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
|
||||
meta_description_translations: dict[str, str] | None = Field(
|
||||
None, description="Meta description translations keyed by language code"
|
||||
)
|
||||
is_published: bool = Field(default=False, description="Publish immediately")
|
||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
|
||||
"""Schema for updating a content page (admin)."""
|
||||
|
||||
title: str | None = Field(None, max_length=200)
|
||||
title_translations: dict[str, str] | None = None
|
||||
content: str | None = None
|
||||
content_translations: dict[str, str] | None = None
|
||||
content_format: str | None = None
|
||||
template: str | None = Field(None, max_length=50)
|
||||
meta_description: str | None = Field(None, max_length=300)
|
||||
meta_keywords: str | None = Field(None, max_length=300)
|
||||
meta_description_translations: dict[str, str] | None = None
|
||||
is_published: bool | None = None
|
||||
show_in_footer: bool | None = None
|
||||
show_in_header: bool | None = None
|
||||
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
|
||||
store_name: str | None
|
||||
slug: str
|
||||
title: str
|
||||
title_translations: dict[str, str] | None = None
|
||||
content: str
|
||||
content_translations: dict[str, str] | None = None
|
||||
content_format: str
|
||||
template: str | None = None
|
||||
meta_description: str | None
|
||||
meta_keywords: str | None
|
||||
meta_description_translations: dict[str, str] | None = None
|
||||
is_published: bool
|
||||
published_at: str | None
|
||||
display_order: int
|
||||
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
|
||||
meta_description: str | None = Field(
|
||||
None, max_length=300, description="SEO meta description"
|
||||
)
|
||||
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
|
||||
is_published: bool = Field(default=False, description="Publish immediately")
|
||||
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
|
||||
show_in_header: bool = Field(default=False, description="Show in header navigation")
|
||||
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
|
||||
content: str | None = None
|
||||
content_format: str | None = None
|
||||
meta_description: str | None = Field(None, max_length=300)
|
||||
meta_keywords: str | None = Field(None, max_length=300)
|
||||
is_published: bool | None = None
|
||||
show_in_footer: bool | None = None
|
||||
show_in_header: bool | None = None
|
||||
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
|
||||
content: str
|
||||
content_format: str
|
||||
meta_description: str | None
|
||||
meta_keywords: str | None
|
||||
published_at: str | None
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -472,7 +473,7 @@ class ContentPageService:
|
||||
content_format: str = "html",
|
||||
template: str = "default",
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool = False,
|
||||
show_in_footer: bool = True,
|
||||
show_in_header: bool = False,
|
||||
@@ -494,7 +495,7 @@ class ContentPageService:
|
||||
content_format: "html" or "markdown"
|
||||
template: Template name for landing pages
|
||||
meta_description: SEO description
|
||||
meta_keywords: SEO keywords
|
||||
meta_description_translations: Meta description translations dict
|
||||
is_published: Publish immediately
|
||||
show_in_footer: Show in footer navigation
|
||||
show_in_header: Show in header navigation
|
||||
@@ -515,7 +516,7 @@ class ContentPageService:
|
||||
content_format=content_format,
|
||||
template=template,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
published_at=datetime.now(UTC) if is_published else None,
|
||||
show_in_footer=show_in_footer,
|
||||
@@ -541,11 +542,13 @@ class ContentPageService:
|
||||
db: Session,
|
||||
page_id: int,
|
||||
title: str | None = None,
|
||||
title_translations: dict[str, str] | None = None,
|
||||
content: str | None = None,
|
||||
content_translations: dict[str, str] | None = None,
|
||||
content_format: str | None = None,
|
||||
template: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
@@ -573,16 +576,20 @@ class ContentPageService:
|
||||
# Update fields if provided
|
||||
if title is not None:
|
||||
page.title = title
|
||||
if title_translations is not None:
|
||||
page.title_translations = title_translations
|
||||
if content is not None:
|
||||
page.content = content
|
||||
if content_translations is not None:
|
||||
page.content_translations = content_translations
|
||||
if content_format is not None:
|
||||
page.content_format = content_format
|
||||
if template is not None:
|
||||
page.template = template
|
||||
if meta_description is not None:
|
||||
page.meta_description = meta_description
|
||||
if meta_keywords is not None:
|
||||
page.meta_keywords = meta_keywords
|
||||
if meta_description_translations is not None:
|
||||
page.meta_description_translations = meta_description_translations
|
||||
if is_published is not None:
|
||||
page.is_published = is_published
|
||||
if is_published and not page.published_at:
|
||||
@@ -698,7 +705,7 @@ class ContentPageService:
|
||||
content: str | None = None,
|
||||
content_format: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
@@ -725,7 +732,7 @@ class ContentPageService:
|
||||
content=content,
|
||||
content_format=content_format,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
@@ -760,7 +767,7 @@ class ContentPageService:
|
||||
content: str,
|
||||
content_format: str = "html",
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool = False,
|
||||
show_in_footer: bool = True,
|
||||
show_in_header: bool = False,
|
||||
@@ -791,7 +798,7 @@ class ContentPageService:
|
||||
is_platform_page=False,
|
||||
content_format=content_format,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
@@ -913,11 +920,13 @@ class ContentPageService:
|
||||
db: Session,
|
||||
page_id: int,
|
||||
title: str | None = None,
|
||||
title_translations: dict[str, str] | None = None,
|
||||
content: str | None = None,
|
||||
content_translations: dict[str, str] | None = None,
|
||||
content_format: str | None = None,
|
||||
template: str | None = None,
|
||||
meta_description: str | None = None,
|
||||
meta_keywords: str | None = None,
|
||||
meta_description_translations: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
show_in_footer: bool | None = None,
|
||||
show_in_header: bool | None = None,
|
||||
@@ -935,11 +944,13 @@ class ContentPageService:
|
||||
db,
|
||||
page_id=page_id,
|
||||
title=title,
|
||||
title_translations=title_translations,
|
||||
content=content,
|
||||
content_translations=content_translations,
|
||||
content_format=content_format,
|
||||
template=template,
|
||||
meta_description=meta_description,
|
||||
meta_keywords=meta_keywords,
|
||||
meta_description_translations=meta_description_translations,
|
||||
is_published=is_published,
|
||||
show_in_footer=show_in_footer,
|
||||
show_in_header=show_in_header,
|
||||
@@ -991,6 +1002,28 @@ class ContentPageService:
|
||||
content = content.replace(placeholder, value)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def resolve_placeholders_deep(data, store) -> Any:
|
||||
"""
|
||||
Recursively resolve {{store_name}} etc. in a nested data structure
|
||||
(dicts, lists, strings). Used for sections JSON in store default pages.
|
||||
"""
|
||||
if not data or not store:
|
||||
return data
|
||||
if isinstance(data, str):
|
||||
return ContentPageService.resolve_placeholders(data, store)
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
k: ContentPageService.resolve_placeholders_deep(v, store)
|
||||
for k, v in data.items()
|
||||
}
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
ContentPageService.resolve_placeholders_deep(item, store)
|
||||
for item in data
|
||||
]
|
||||
return data
|
||||
|
||||
# =========================================================================
|
||||
# Homepage Sections Management
|
||||
# =========================================================================
|
||||
|
||||
@@ -70,7 +70,7 @@ class StoreThemeService:
|
||||
"""
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
|
||||
store = store_service.get_store_by_code(db, store_code)
|
||||
store = store_service.get_store_by_code_or_subdomain(db, store_code)
|
||||
|
||||
if not store:
|
||||
self.logger.warning(f"Store not found: {store_code}")
|
||||
|
||||
@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
|
||||
form: {
|
||||
slug: '',
|
||||
title: '',
|
||||
title_translations: {},
|
||||
content: '',
|
||||
content_translations: {},
|
||||
content_format: 'html',
|
||||
template: 'default',
|
||||
meta_description: '',
|
||||
meta_keywords: '',
|
||||
meta_description_translations: {},
|
||||
is_published: false,
|
||||
show_in_header: false,
|
||||
show_in_footer: true,
|
||||
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
|
||||
error: null,
|
||||
successMessage: null,
|
||||
|
||||
// Page type: 'content' or 'landing'
|
||||
pageType: 'content',
|
||||
|
||||
// Translation language for title/content
|
||||
titleContentLang: 'fr',
|
||||
|
||||
// ========================================
|
||||
// HOMEPAGE SECTIONS STATE
|
||||
// ========================================
|
||||
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
|
||||
de: 'Deutsch',
|
||||
lb: 'Lëtzebuergesch'
|
||||
},
|
||||
|
||||
// Template-driven section palette
|
||||
sectionPalette: {
|
||||
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
|
||||
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
|
||||
},
|
||||
|
||||
sections: {
|
||||
hero: {
|
||||
enabled: true,
|
||||
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
|
||||
await this.loadPage();
|
||||
contentPageEditLog.groupEnd();
|
||||
|
||||
// Load sections if this is a homepage
|
||||
if (this.form.slug === 'home') {
|
||||
// Load sections if this is a landing page
|
||||
if (this.pageType === 'landing') {
|
||||
await this.loadSections();
|
||||
}
|
||||
} else {
|
||||
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
|
||||
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
// Check if we should show section editor (property, not getter for Alpine compatibility)
|
||||
// Check if we should show section editor
|
||||
isHomepage: false,
|
||||
|
||||
// Update isHomepage when slug changes
|
||||
// Is a section available for the current template?
|
||||
isSectionAvailable(sectionName) {
|
||||
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
|
||||
return palette.includes(sectionName);
|
||||
},
|
||||
|
||||
// Update homepage state
|
||||
updateIsHomepage() {
|
||||
this.isHomepage = this.form.slug === 'home';
|
||||
},
|
||||
|
||||
// Update template when page type changes
|
||||
updatePageType() {
|
||||
if (this.pageType === 'landing') {
|
||||
this.form.template = 'full';
|
||||
// Load sections if editing and not yet loaded
|
||||
if (this.pageId && !this.sectionsLoaded) {
|
||||
this.loadSections();
|
||||
}
|
||||
} else {
|
||||
this.form.template = 'default';
|
||||
}
|
||||
this.updateIsHomepage();
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// TITLE/CONTENT TRANSLATION HELPERS
|
||||
// ========================================
|
||||
|
||||
getTranslatedTitle() {
|
||||
if (this.titleContentLang === this.defaultLanguage) {
|
||||
return this.form.title;
|
||||
}
|
||||
return (this.form.title_translations || {})[this.titleContentLang] || '';
|
||||
},
|
||||
|
||||
setTranslatedTitle(value) {
|
||||
if (this.titleContentLang === this.defaultLanguage) {
|
||||
this.form.title = value;
|
||||
} else {
|
||||
if (!this.form.title_translations) this.form.title_translations = {};
|
||||
this.form.title_translations[this.titleContentLang] = value;
|
||||
}
|
||||
},
|
||||
|
||||
getTranslatedContent() {
|
||||
if (this.titleContentLang === this.defaultLanguage) {
|
||||
return this.form.content;
|
||||
}
|
||||
return (this.form.content_translations || {})[this.titleContentLang] || '';
|
||||
},
|
||||
|
||||
setTranslatedContent(value) {
|
||||
if (this.titleContentLang === this.defaultLanguage) {
|
||||
this.form.content = value;
|
||||
} else {
|
||||
if (!this.form.content_translations) this.form.content_translations = {};
|
||||
this.form.content_translations[this.titleContentLang] = value;
|
||||
}
|
||||
},
|
||||
|
||||
getTranslatedMetaDescription() {
|
||||
if (this.titleContentLang === this.defaultLanguage) {
|
||||
return this.form.meta_description;
|
||||
}
|
||||
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
|
||||
},
|
||||
|
||||
setTranslatedMetaDescription(value) {
|
||||
if (this.titleContentLang === this.defaultLanguage) {
|
||||
this.form.meta_description = value;
|
||||
} else {
|
||||
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
|
||||
this.form.meta_description_translations[this.titleContentLang] = value;
|
||||
}
|
||||
},
|
||||
|
||||
// Load platforms for dropdown
|
||||
async loadPlatforms() {
|
||||
this.loadingPlatforms = true;
|
||||
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
|
||||
this.form = {
|
||||
slug: page.slug || '',
|
||||
title: page.title || '',
|
||||
title_translations: page.title_translations || {},
|
||||
content: page.content || '',
|
||||
content_translations: page.content_translations || {},
|
||||
content_format: page.content_format || 'html',
|
||||
template: page.template || 'default',
|
||||
meta_description: page.meta_description || '',
|
||||
meta_keywords: page.meta_keywords || '',
|
||||
meta_description_translations: page.meta_description_translations || {},
|
||||
is_published: page.is_published || false,
|
||||
show_in_header: page.show_in_header || false,
|
||||
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
|
||||
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
|
||||
store_id: page.store_id
|
||||
};
|
||||
|
||||
// Set page type from template
|
||||
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
|
||||
|
||||
contentPageEditLog.info('Page loaded successfully');
|
||||
|
||||
// Update computed properties after loading
|
||||
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// HOMEPAGE SECTIONS METHODS
|
||||
// SECTIONS METHODS
|
||||
// ========================================
|
||||
|
||||
// Load sections for homepage
|
||||
// Load sections for landing pages
|
||||
async loadSections() {
|
||||
if (!this.pageId || this.form.slug !== 'home') {
|
||||
contentPageEditLog.debug('Skipping section load - not a homepage');
|
||||
if (!this.pageId || this.pageType !== 'landing') {
|
||||
contentPageEditLog.debug('Skipping section load - not a landing page');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
contentPageEditLog.info('Loading homepage sections...');
|
||||
contentPageEditLog.info('Loading sections...');
|
||||
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
|
||||
const data = response.data || response;
|
||||
|
||||
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
|
||||
this.defaultLanguage = data.default_language || 'fr';
|
||||
this.currentLang = this.defaultLanguage;
|
||||
this.titleContentLang = this.defaultLanguage;
|
||||
|
||||
if (data.sections) {
|
||||
this.sections = this.mergeWithDefaults(data.sections);
|
||||
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
|
||||
mergeWithDefaults(loadedSections) {
|
||||
const defaults = this.getDefaultSectionStructure();
|
||||
|
||||
// Deep merge each section
|
||||
for (const key of ['hero', 'features', 'pricing', 'cta']) {
|
||||
// Deep merge each section that exists in defaults
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (loadedSections[key]) {
|
||||
defaults[key] = { ...defaults[key], ...loadedSections[key] };
|
||||
}
|
||||
}
|
||||
// Also preserve any extra sections from loaded data
|
||||
for (const key of Object.keys(loadedSections)) {
|
||||
if (!defaults[key]) {
|
||||
defaults[key] = loadedSections[key];
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
},
|
||||
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
|
||||
|
||||
// Save sections
|
||||
async saveSections() {
|
||||
if (!this.pageId || !this.isHomepage) return;
|
||||
if (!this.pageId || this.pageType !== 'landing') return;
|
||||
|
||||
try {
|
||||
contentPageEditLog.info('Saving sections...');
|
||||
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
|
||||
const payload = {
|
||||
slug: this.form.slug,
|
||||
title: this.form.title,
|
||||
title_translations: this.form.title_translations,
|
||||
content: this.form.content,
|
||||
content_translations: this.form.content_translations,
|
||||
content_format: this.form.content_format,
|
||||
template: this.form.template,
|
||||
meta_description: this.form.meta_description,
|
||||
meta_keywords: this.form.meta_keywords,
|
||||
meta_description_translations: this.form.meta_description_translations,
|
||||
is_published: this.form.is_published,
|
||||
show_in_header: this.form.show_in_header,
|
||||
show_in_footer: this.form.show_in_footer,
|
||||
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
|
||||
// Update existing page
|
||||
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
|
||||
|
||||
// Also save sections if this is a homepage
|
||||
if (this.isHomepage && this.sectionsLoaded) {
|
||||
// Also save sections if this is a landing page
|
||||
if (this.pageType === 'landing' && this.sectionsLoaded) {
|
||||
await this.saveSections();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,19 +57,23 @@
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Page Title -->
|
||||
<div class="md:col-span-2">
|
||||
<!-- Page Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Page Title <span class="text-red-500">*</span>
|
||||
Page Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.title"
|
||||
required
|
||||
maxlength="200"
|
||||
<select
|
||||
x-model="pageType"
|
||||
@change="updatePageType()"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
placeholder="About Us"
|
||||
>
|
||||
<option value="content">Content Page</option>
|
||||
<option value="landing">Landing Page</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
|
||||
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
@@ -133,10 +137,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<!-- Title with Language Tabs -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Page Title
|
||||
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
|
||||
</h3>
|
||||
|
||||
<!-- Language Tabs for Title/Content -->
|
||||
<div class="mb-4">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex -mb-px space-x-4">
|
||||
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
|
||||
<button
|
||||
type="button"
|
||||
@click="titleContentLang = lang"
|
||||
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
|
||||
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Title <span class="text-red-500">*</span>
|
||||
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="getTranslatedTitle()"
|
||||
@input="setTranslatedTitle($event.target.value)"
|
||||
required
|
||||
maxlength="200"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content (only for Content Page type) -->
|
||||
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Page Content
|
||||
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</h3>
|
||||
|
||||
<!-- Content Format -->
|
||||
@@ -219,9 +267,9 @@
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
|
||||
<!-- SECTIONS EDITOR (for Landing Page type) -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Homepage Sections
|
||||
@@ -258,7 +306,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- HERO SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'hero' ? null : 'hero'"
|
||||
@@ -341,7 +389,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- FEATURES SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'features' ? null : 'features'"
|
||||
@@ -410,7 +458,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- PRICING SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
|
||||
@@ -448,7 +496,7 @@
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- CTA SECTION -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
@click="openSection = openSection === 'cta' ? null : 'cta'"
|
||||
@@ -525,6 +573,7 @@
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
SEO & Metadata
|
||||
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -534,30 +583,17 @@
|
||||
Meta Description
|
||||
</label>
|
||||
<textarea
|
||||
x-model="form.meta_description"
|
||||
:value="getTranslatedMetaDescription()"
|
||||
@input="setTranslatedMetaDescription($event.target.value)"
|
||||
rows="2"
|
||||
maxlength="300"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
placeholder="A brief description for search engines"
|
||||
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
|
||||
150-160 characters recommended for search engines
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Meta Keywords -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Meta Keywords
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.meta_keywords"
|
||||
maxlength="300"
|
||||
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
{% from 'cms/platform/sections/_products.html' import render_products %}
|
||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
|
||||
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||
|
||||
{% block title %}
|
||||
@@ -51,6 +54,21 @@
|
||||
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
|
||||
{% endif %}
|
||||
|
||||
{# Testimonials Section #}
|
||||
{% if page.sections.testimonials %}
|
||||
{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# Gallery Section #}
|
||||
{% if page.sections.gallery %}
|
||||
{{ render_gallery(page.sections.gallery, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# Contact Info Section #}
|
||||
{% if page.sections.contact_info %}
|
||||
{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}
|
||||
{% endif %}
|
||||
|
||||
{# CTA Section #}
|
||||
{% if page.sections.cta %}
|
||||
{{ render_cta(page.sections.cta, lang, default_lang) }}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{# Section partial: Contact Information #}
|
||||
{#
|
||||
Parameters:
|
||||
- contact_info: dict with enabled, title, email, phone, address, hours, map_embed_url
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
#}
|
||||
|
||||
{% macro render_contact_info(contact_info, lang, default_lang) %}
|
||||
{% if contact_info and contact_info.enabled %}
|
||||
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-12">
|
||||
{% set title = contact_info.title.translations.get(lang) or contact_info.title.translations.get(default_lang) or 'Contact' %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
{% if contact_info.phone %}
|
||||
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-purple-600 dark:text-purple-300 text-xl">📞</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Phone</h3>
|
||||
<a href="tel:{{ contact_info.phone }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||
{{ contact_info.phone }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact_info.email %}
|
||||
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-purple-600 dark:text-purple-300 text-xl">📧</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h3>
|
||||
<a href="mailto:{{ contact_info.email }}" class="text-purple-600 dark:text-purple-400 hover:underline">
|
||||
{{ contact_info.email }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact_info.address %}
|
||||
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-purple-600 dark:text-purple-300 text-xl">📍</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Address</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ contact_info.address }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if contact_info.hours %}
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-semibold">Hours:</span> {{ contact_info.hours }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{# Section partial: Image Gallery #}
|
||||
{#
|
||||
Parameters:
|
||||
- gallery: dict with enabled, title, images (list of {src, alt, caption})
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
#}
|
||||
|
||||
{% macro render_gallery(gallery, lang, default_lang) %}
|
||||
{% if gallery and gallery.enabled %}
|
||||
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Section header #}
|
||||
<div class="text-center mb-12">
|
||||
{% set title = gallery.title.translations.get(lang) or gallery.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Image grid #}
|
||||
{% if gallery.images %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{% for image in gallery.images %}
|
||||
<div class="relative group overflow-hidden rounded-lg aspect-square">
|
||||
<img src="{{ image.src }}"
|
||||
alt="{{ image.alt or '' }}"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy">
|
||||
{% if image.caption %}
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p class="text-sm text-white">{{ image.caption }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{# Section partial: Testimonials #}
|
||||
{#
|
||||
Parameters:
|
||||
- testimonials: dict with enabled, title, subtitle, items
|
||||
- lang: Current language code
|
||||
- default_lang: Fallback language
|
||||
#}
|
||||
|
||||
{% macro render_testimonials(testimonials, lang, default_lang) %}
|
||||
{% if testimonials and testimonials.enabled %}
|
||||
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Section header #}
|
||||
<div class="text-center mb-12">
|
||||
{% set title = testimonials.title.translations.get(lang) or testimonials.title.translations.get(default_lang) or '' %}
|
||||
{% if title %}
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ title }}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Testimonial cards — use .get() to avoid dict.items() method collision with JSON dicts #}
|
||||
{% set testimonial_items = testimonials.get('items', []) if testimonials is mapping else [] %}
|
||||
{% if testimonial_items %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{% for item in testimonial_items %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex text-yellow-400">
|
||||
{% for _ in range(5) %}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% set content = item.content %}
|
||||
{% if content is mapping %}
|
||||
{% set content = content.translations.get(lang) or content.translations.get(default_lang) or '' %}
|
||||
{% endif %}
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6 italic">"{{ content }}"</p>
|
||||
<div class="flex items-center">
|
||||
{% if item.avatar %}
|
||||
<img src="{{ item.avatar }}" alt="" class="w-10 h-10 rounded-full mr-3">
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-3">
|
||||
<span class="text-sm font-bold text-purple-600 dark:text-purple-300">
|
||||
{% set author = item.author %}
|
||||
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '?' %}{% endif %}
|
||||
{{ author[0]|upper if author else '?' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{% set author = item.author %}
|
||||
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '' %}{% endif %}
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ author }}</p>
|
||||
{% set role = item.role %}
|
||||
{% if role is mapping %}{% set role = role.translations.get(lang) or role.translations.get(default_lang) or '' %}{% endif %}
|
||||
{% if role %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ role }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-gray-400 dark:text-gray-500">Coming soon</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{# Dynamic title from CMS #}
|
||||
{% block title %}{{ page.title }}{% endblock %}
|
||||
{% block title %}{{ page_title or page.title }}{% endblock %}
|
||||
|
||||
{# SEO from CMS #}
|
||||
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
|
||||
@@ -16,13 +16,13 @@
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
|
||||
</div>
|
||||
|
||||
{# Page Header #}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
|
||||
{{ page.title }}
|
||||
{{ page_title or page.title }}
|
||||
</h1>
|
||||
|
||||
{# Optional: Show store override badge for debugging #}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{# app/templates/store/landing-default.html #}
|
||||
{# standalone #}
|
||||
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
|
||||
{# Default/Minimal Landing Page Template #}
|
||||
{% extends "storefront/base.html" %}
|
||||
|
||||
{% block title %}{{ store.name }}{% endblock %}
|
||||
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
|
||||
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen">
|
||||
@@ -24,7 +23,7 @@
|
||||
|
||||
{# Title #}
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{{ page.title or store.name }}
|
||||
{{ page_title or store.name }}
|
||||
</h1>
|
||||
|
||||
{# Tagline #}
|
||||
@@ -34,18 +33,31 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# CTA Button #}
|
||||
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
|
||||
{% set nav_items = storefront_nav.get('nav', []) %}
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="{{ base_url }}"
|
||||
{% if nav_items %}
|
||||
{# Primary CTA: first nav item from enabled modules #}
|
||||
<a href="{{ base_url }}{{ nav_items[0].route }}"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||
style="background-color: var(--color-primary)">
|
||||
Browse Our Shop
|
||||
{{ _(nav_items[0].label_key) }}
|
||||
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
{% if page.content %}
|
||||
{% else %}
|
||||
{# Fallback: account link when no module nav items #}
|
||||
<a href="{{ base_url }}account/login"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
|
||||
style="background-color: var(--color-primary)">
|
||||
{{ _('cms.storefront.my_account') }}
|
||||
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if page and page.content %}
|
||||
<a href="#about"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
|
||||
Learn More
|
||||
{{ _('cms.storefront.learn_more') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -54,73 +66,65 @@
|
||||
</section>
|
||||
|
||||
{# Content Section (if provided) #}
|
||||
{% if page.content %}
|
||||
{% if page_content %}
|
||||
<section id="about" class="py-16 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
||||
{{ page_content | safe }}{# sanitized: CMS content #}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# Quick Links Section #}
|
||||
{# Quick Links Section — driven by nav items and CMS pages #}
|
||||
{% set account_items = storefront_nav.get('account', []) %}
|
||||
{% set all_links = nav_items + account_items %}
|
||||
{% if all_links or header_pages %}
|
||||
<section class="py-16 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
|
||||
Explore
|
||||
{{ _('cms.storefront.explore') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<a href="{{ base_url }}products"
|
||||
{# Module nav items (products, loyalty, etc.) #}
|
||||
{% for item in all_links[:3] %}
|
||||
<a href="{{ base_url }}{{ item.route }}"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">🛍️</div>
|
||||
<div class="mb-4">
|
||||
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
Shop Products
|
||||
{{ _(item.label_key) }}
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Browse our complete catalog
|
||||
</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if header_pages %}
|
||||
{% for page in header_pages[:2] %}
|
||||
{# Fill remaining slots with CMS header pages #}
|
||||
{% set remaining = 3 - all_links[:3]|length %}
|
||||
{% if remaining > 0 and header_pages %}
|
||||
{% for page in header_pages[:remaining] %}
|
||||
<a href="{{ base_url }}{{ page.slug }}"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">📄</div>
|
||||
<div class="mb-4">
|
||||
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
|
||||
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
{{ page.title }}
|
||||
</h3>
|
||||
{% if page.meta_description %}
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{{ page.meta_description or 'Learn more' }}
|
||||
{{ page.meta_description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<a href="{{ base_url }}about"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">ℹ️</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
About Us
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Learn about our story
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ base_url }}contact"
|
||||
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
|
||||
<div class="text-4xl mb-4">📧</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
|
||||
Contact
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Get in touch with us
|
||||
</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,6 +10,35 @@
|
||||
{% block alpine_data %}storefrontLayoutData(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{# SECTION-BASED RENDERING (when page.sections is configured) #}
|
||||
{# Used by POC builder templates — takes priority over hardcoded HTML #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
|
||||
{% if sections %}
|
||||
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
|
||||
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
|
||||
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
|
||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||
|
||||
{% set lang = request.state.language|default("fr") %}
|
||||
{% set default_lang = 'fr' %}
|
||||
|
||||
<div class="min-h-screen">
|
||||
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
|
||||
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
|
||||
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
|
||||
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
|
||||
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
|
||||
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
{# HARDCODED LAYOUT (original full landing page — no sections JSON) #}
|
||||
{# ═══════════════════════════════════════════════════════════════════ #}
|
||||
<div class="min-h-screen">
|
||||
|
||||
{# Hero Section - Split Design #}
|
||||
@@ -255,4 +284,5 @@
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -80,6 +80,44 @@ class WidgetContext:
|
||||
include_details: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Storefront Dashboard Card
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorefrontDashboardCard:
|
||||
"""
|
||||
A card contributed by a module to the storefront customer dashboard.
|
||||
|
||||
Modules implement get_storefront_dashboard_cards() to provide these.
|
||||
The dashboard template renders them without knowing which module provided them.
|
||||
|
||||
Attributes:
|
||||
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
|
||||
icon: Lucide icon name (e.g. "shopping-bag", "gift")
|
||||
title: Card title (i18n key or plain text)
|
||||
subtitle: Card subtitle / description
|
||||
route: Link destination relative to base_url (e.g. "account/orders")
|
||||
value: Primary display value (e.g. order count, points balance)
|
||||
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
|
||||
order: Sort order (lower = shown first)
|
||||
template: Optional custom template path for complex rendering
|
||||
extra_data: Additional data for custom template rendering
|
||||
"""
|
||||
|
||||
key: str
|
||||
icon: str
|
||||
title: str
|
||||
subtitle: str
|
||||
route: str
|
||||
value: str | int | None = None
|
||||
value_label: str | None = None
|
||||
order: int = 100
|
||||
template: str | None = None
|
||||
extra_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Widget Item Types
|
||||
# =============================================================================
|
||||
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_storefront_dashboard_cards(
|
||||
self,
|
||||
db: "Session",
|
||||
store_id: int,
|
||||
customer_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list["StorefrontDashboardCard"]:
|
||||
"""
|
||||
Get cards for the storefront customer dashboard.
|
||||
|
||||
Called by the customer account dashboard. Each module contributes
|
||||
its own cards (e.g. orders summary, loyalty points).
|
||||
|
||||
Args:
|
||||
db: Database session for queries
|
||||
store_id: ID of the store
|
||||
customer_id: ID of the logged-in customer
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
List of StorefrontDashboardCard objects
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Context
|
||||
@@ -343,6 +405,8 @@ __all__ = [
|
||||
"WidgetData",
|
||||
# Main envelope
|
||||
"DashboardWidget",
|
||||
# Storefront
|
||||
"StorefrontDashboardCard",
|
||||
# Protocol
|
||||
"DashboardWidgetProviderProtocol",
|
||||
]
|
||||
|
||||
@@ -9,11 +9,13 @@ Store pages for core functionality:
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
UserContext,
|
||||
get_current_store_from_cookie_or_header,
|
||||
get_current_store_optional,
|
||||
get_db,
|
||||
get_resolved_store_code,
|
||||
)
|
||||
@@ -24,6 +26,21 @@ from app.templates_config import templates
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE ROOT REDIRECT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def store_root(
|
||||
current_user: UserContext | None = Depends(get_current_store_optional),
|
||||
):
|
||||
"""Redirect /store/ based on authentication status."""
|
||||
if current_user:
|
||||
return RedirectResponse(url="/store/dashboard", status_code=302)
|
||||
return RedirectResponse(url="/store/login", status_code=302)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE DASHBOARD
|
||||
# ============================================================================
|
||||
|
||||
@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
|
||||
section_order: int
|
||||
is_visible: bool = True
|
||||
is_module_enabled: bool = True
|
||||
header_template: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
|
||||
section_label_key=section.label_key,
|
||||
section_order=section.order,
|
||||
is_module_enabled=is_module_enabled,
|
||||
header_template=item.header_template,
|
||||
)
|
||||
sections_map[section.id].items.append(discovered_item)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
|
||||
from app.modules.contracts.widgets import (
|
||||
DashboardWidget,
|
||||
DashboardWidgetProviderProtocol,
|
||||
StorefrontDashboardCard,
|
||||
WidgetContext,
|
||||
)
|
||||
|
||||
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_storefront_dashboard_cards(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
customer_id: int,
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[StorefrontDashboardCard]:
|
||||
"""
|
||||
Get dashboard cards for the storefront customer account page.
|
||||
|
||||
Collects cards from all enabled modules that implement
|
||||
get_storefront_dashboard_cards(), sorted by order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: ID of the store
|
||||
customer_id: ID of the logged-in customer
|
||||
platform_id: Platform ID (for module enablement check)
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
Flat list of StorefrontDashboardCard sorted by order
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
cards: list[StorefrontDashboardCard] = []
|
||||
|
||||
for module, provider in providers:
|
||||
if not hasattr(provider, "get_storefront_dashboard_cards"):
|
||||
continue
|
||||
try:
|
||||
module_cards = provider.get_storefront_dashboard_cards(
|
||||
db, store_id, customer_id, context
|
||||
)
|
||||
if module_cards:
|
||||
cards.extend(module_cards)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get storefront cards from module {module.code}: {e}"
|
||||
)
|
||||
|
||||
return sorted(cards, key=lambda c: c.order)
|
||||
|
||||
def get_available_categories(
|
||||
self, db: Session, platform_id: int
|
||||
) -> list[str]:
|
||||
|
||||
@@ -278,7 +278,7 @@ class TestMerchantMenuModuleGating:
|
||||
s for s in data["sections"] if s["id"] == platform_section_id
|
||||
)
|
||||
item_ids = {i["id"] for i in platform_section["items"]}
|
||||
assert "loyalty-program" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_loyalty_hidden_when_module_not_enabled(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
@@ -304,7 +304,7 @@ class TestMerchantMenuModuleGating:
|
||||
s for s in data["sections"] if s["id"] == platform_section_id
|
||||
)
|
||||
overview = next(
|
||||
i for i in platform_section["items"] if i["id"] == "loyalty-program"
|
||||
i for i in platform_section["items"] if i["id"] == "program"
|
||||
)
|
||||
assert overview["url"] == "/merchants/loyalty/program"
|
||||
|
||||
@@ -498,7 +498,7 @@ class TestMerchantMenuMultiPlatform:
|
||||
s for s in data["sections"] if s["id"] == platform_a_section_id
|
||||
)
|
||||
item_ids = {i["id"] for i in pa_section["items"]}
|
||||
assert "loyalty-program" in item_ids
|
||||
assert "program" in item_ids
|
||||
# Core sections always present
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
|
||||
@@ -61,12 +61,12 @@ class TestMenuDiscoveryService:
|
||||
assert "profile" in item_ids
|
||||
|
||||
def test_merchant_loyalty_section_items(self):
|
||||
"""Loyalty section contains loyalty-program."""
|
||||
"""Loyalty section contains program."""
|
||||
menus = self.service.discover_all_menus()
|
||||
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
|
||||
assert len(loyalty_sections) == 1
|
||||
item_ids = [i.id for i in loyalty_sections[0].items]
|
||||
assert "loyalty-program" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_get_all_menu_items_merchant(self):
|
||||
"""get_all_menu_items returns items for MERCHANT frontend type."""
|
||||
@@ -75,7 +75,7 @@ class TestMenuDiscoveryService:
|
||||
item_ids = {i.id for i in items}
|
||||
assert "dashboard" in item_ids
|
||||
assert "subscriptions" in item_ids
|
||||
assert "loyalty-program" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_get_all_menu_item_ids_merchant(self):
|
||||
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
|
||||
@@ -85,7 +85,7 @@ class TestMenuDiscoveryService:
|
||||
assert "invoices" in item_ids
|
||||
assert "stores" in item_ids
|
||||
assert "profile" in item_ids
|
||||
assert "loyalty-program" in item_ids
|
||||
assert "program" in item_ids
|
||||
|
||||
def test_get_mandatory_item_ids_merchant(self):
|
||||
"""Mandatory items for MERCHANT include dashboard and subscriptions."""
|
||||
|
||||
@@ -384,15 +384,17 @@ def get_storefront_context(
|
||||
if access_method == "path" and store:
|
||||
platform = getattr(request.state, "platform", None)
|
||||
platform_original_path = getattr(request.state, "platform_original_path", None)
|
||||
# Use subdomain (lowercase, hyphens) for URL routing — store_code is for internal use
|
||||
store_slug = store.subdomain or store.store_code
|
||||
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
|
||||
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
|
||||
base_url = f"/platforms/{platform.code}/storefront/{store_slug}/"
|
||||
else:
|
||||
full_prefix = (
|
||||
store_context.get("full_prefix", "/storefront/")
|
||||
if store_context
|
||||
else "/storefront/"
|
||||
)
|
||||
base_url = f"{full_prefix}{store.store_code}/"
|
||||
base_url = f"{full_prefix}{store_slug}/"
|
||||
|
||||
# Read subscription info set by StorefrontAccessMiddleware
|
||||
subscription = getattr(request.state, "subscription", None)
|
||||
|
||||
@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="dashboard",
|
||||
label_key="storefront.account.dashboard",
|
||||
label_key="customers.storefront.account.dashboard",
|
||||
icon="home",
|
||||
route="account/dashboard",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="profile",
|
||||
label_key="storefront.account.profile",
|
||||
label_key="customers.storefront.account.profile",
|
||||
icon="user",
|
||||
route="account/profile",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="addresses",
|
||||
label_key="storefront.account.addresses",
|
||||
label_key="customers.storefront.account.addresses",
|
||||
icon="map-pin",
|
||||
route="account/addresses",
|
||||
order=30,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="settings",
|
||||
label_key="storefront.account.settings",
|
||||
label_key="customers.storefront.account.settings",
|
||||
icon="cog",
|
||||
route="account/settings",
|
||||
order=90,
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_delete_desc": "Kundendatensätze entfernen",
|
||||
"customers_export": "Kunden exportieren",
|
||||
"customers_export_desc": "Kundendaten exportieren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adressen",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_section": "Customers",
|
||||
"customers": "Customers",
|
||||
"all_customers": "All Customers"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profile",
|
||||
"addresses": "Addresses",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_delete_desc": "Supprimer les fiches clients",
|
||||
"customers_export": "Exporter les clients",
|
||||
"customers_export_desc": "Exporter les données clients"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adresses",
|
||||
"settings": "Paramètres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,5 +52,13 @@
|
||||
"customers_delete_desc": "Clientedossieren ewechhuelen",
|
||||
"customers_export": "Clienten exportéieren",
|
||||
"customers_export_desc": "Clientedaten exportéieren"
|
||||
},
|
||||
"storefront": {
|
||||
"account": {
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profil",
|
||||
"addresses": "Adressen",
|
||||
"settings": "Astellungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""customers 003 - add birth_date column
|
||||
|
||||
Adds an optional birth_date column to the customers table so that
|
||||
self-enrollment flows (e.g. loyalty) can persist the customer's birthday
|
||||
collected on the enrollment form. Previously the field was collected by
|
||||
the UI and accepted by the loyalty service signature, but never written
|
||||
anywhere — see Phase 1.4 of the loyalty production launch plan.
|
||||
|
||||
Revision ID: customers_003
|
||||
Revises: customers_002
|
||||
Create Date: 2026-04-09
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "customers_003"
|
||||
down_revision = "customers_002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"customers",
|
||||
sa.Column("birth_date", sa.Date(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("customers", "birth_date")
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
@@ -34,6 +35,7 @@ class Customer(Base, TimestampMixin, SoftDeleteMixin):
|
||||
first_name = Column(String(100))
|
||||
last_name = Column(String(100))
|
||||
phone = Column(String(50))
|
||||
birth_date = Column(Date, nullable=True)
|
||||
customer_number = Column(
|
||||
String(100), nullable=False, index=True
|
||||
) # Store-specific ID
|
||||
|
||||
@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
|
||||
},
|
||||
)
|
||||
|
||||
# Collect dashboard cards from enabled modules via widget protocol
|
||||
from app.modules.core.services.widget_aggregator import widget_aggregator
|
||||
|
||||
store = getattr(request.state, "store", None)
|
||||
platform = getattr(request.state, "platform", None)
|
||||
dashboard_cards = []
|
||||
if store and platform:
|
||||
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
|
||||
db,
|
||||
store_id=store.id,
|
||||
customer_id=current_customer.id,
|
||||
platform_id=platform.id,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"customers/storefront/dashboard.html",
|
||||
get_storefront_context(request, db=db, user=current_customer),
|
||||
get_storefront_context(
|
||||
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Provides schemas for:
|
||||
- Admin customer management
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
@@ -60,6 +60,9 @@ class CustomerUpdate(BaseModel):
|
||||
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||
last_name: str | None = Field(None, min_length=1, max_length=100)
|
||||
phone: str | None = Field(None, max_length=50)
|
||||
birth_date: date | None = Field(
|
||||
None, description="Date of birth (YYYY-MM-DD)"
|
||||
)
|
||||
marketing_consent: bool | None = None
|
||||
preferred_language: str | None = Field(
|
||||
None, description="Preferred language (en, fr, de, lb)"
|
||||
@@ -71,6 +74,21 @@ class CustomerUpdate(BaseModel):
|
||||
"""Convert email to lowercase."""
|
||||
return v.lower() if v else None
|
||||
|
||||
@field_validator("birth_date")
|
||||
@classmethod
|
||||
def birth_date_sane(cls, v: date | None) -> date | None:
|
||||
"""Birthday must be in the past and within a plausible age range."""
|
||||
if v is None:
|
||||
return v
|
||||
today = date.today()
|
||||
if v >= today:
|
||||
raise ValueError("birth_date must be in the past")
|
||||
# Plausible human age range — guards against typos like 0001-01-01
|
||||
years = (today - v).days / 365.25
|
||||
if years < 13 or years > 120:
|
||||
raise ValueError("birth_date implies an implausible age")
|
||||
return v
|
||||
|
||||
|
||||
class CustomerPasswordChange(BaseModel):
|
||||
"""Schema for customer password change."""
|
||||
@@ -108,6 +126,7 @@ class CustomerResponse(BaseModel):
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
phone: str | None
|
||||
birth_date: date | None = None
|
||||
customer_number: str
|
||||
marketing_consent: bool
|
||||
preferred_language: str | None
|
||||
@@ -253,6 +272,7 @@ class CustomerDetailResponse(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
phone: str | None = None
|
||||
birth_date: date | None = None
|
||||
customer_number: str | None = None
|
||||
marketing_consent: bool | None = None
|
||||
preferred_language: str | None = None
|
||||
@@ -304,6 +324,7 @@ class AdminCustomerItem(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
phone: str | None = None
|
||||
birth_date: date | None = None
|
||||
customer_number: str
|
||||
marketing_consent: bool = False
|
||||
preferred_language: str | None = None
|
||||
|
||||
@@ -7,7 +7,7 @@ with complete store isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
@@ -567,6 +567,7 @@ class CustomerService:
|
||||
first_name: str = "",
|
||||
last_name: str = "",
|
||||
phone: str | None = None,
|
||||
birth_date: date | None = None,
|
||||
) -> Customer:
|
||||
"""
|
||||
Create a customer for loyalty/external enrollment.
|
||||
@@ -580,6 +581,7 @@ class CustomerService:
|
||||
first_name: First name
|
||||
last_name: Last name
|
||||
phone: Phone number
|
||||
birth_date: Date of birth (optional)
|
||||
|
||||
Returns:
|
||||
Created Customer object
|
||||
@@ -603,6 +605,7 @@ class CustomerService:
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
phone=phone,
|
||||
birth_date=birth_date,
|
||||
hashed_password=unusable_hash,
|
||||
customer_number=cust_number,
|
||||
store_id=store_id,
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && !error && addresses.length === 0"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('location-marker', 'h-12 w-12 mx-auto')"></span>
|
||||
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
|
||||
<button @click="openAddModal()"
|
||||
|
||||
@@ -17,25 +17,31 @@
|
||||
<!-- Dashboard Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Orders Card -->
|
||||
<a href="{{ base_url }}account/orders"
|
||||
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
|
||||
{% for card in dashboard_cards|default([]) %}
|
||||
<a href="{{ base_url }}{{ card.route }}"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if card.value is not none %}
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
|
||||
{% if card.value_label %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Profile Card -->
|
||||
<!-- Profile Card (always shown — core) -->
|
||||
<a href="{{ base_url }}account/profile"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
@@ -52,12 +58,12 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Addresses Card -->
|
||||
<!-- Addresses Card (always shown — core) -->
|
||||
<a href="{{ base_url }}account/addresses"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
|
||||
@@ -66,36 +72,7 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% if 'loyalty' in enabled_modules %}
|
||||
<!-- Loyalty Rewards Card -->
|
||||
<a href="{{ base_url }}account/loyalty"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ points: null, loaded: false }"
|
||||
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<template x-if="loaded && points !== null">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="loaded && points === null">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Messages Card -->
|
||||
<!-- Messages Card (always shown — messaging is core) -->
|
||||
<a href="{{ base_url }}account/messages"
|
||||
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
|
||||
x-data="{ unreadCount: 0 }"
|
||||
@@ -126,10 +103,6 @@
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Orders</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
|
||||
|
||||
@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
|
||||
|
||||
start = time.perf_counter()
|
||||
result = connection.execute(text(sql))
|
||||
columns = list(result.keys()) if result.returns_rows else []
|
||||
rows_raw = result.fetchmany(max_rows + 1)
|
||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
|
||||
columns = list(result.keys()) if result.returns_rows else []
|
||||
truncated = len(rows_raw) > max_rows
|
||||
rows_raw = rows_raw[:max_rows]
|
||||
|
||||
|
||||
@@ -40,129 +40,272 @@ function sqlQueryTool() {
|
||||
// Schema explorer
|
||||
showPresets: true,
|
||||
expandedCategories: {},
|
||||
presetQueries: [
|
||||
presetSearch: '',
|
||||
|
||||
// Preset sections — grouped by platform
|
||||
presetSections: [
|
||||
// ── Infrastructure ──
|
||||
{
|
||||
category: 'Schema',
|
||||
items: [
|
||||
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
|
||||
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
|
||||
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
|
||||
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
|
||||
label: 'Infrastructure',
|
||||
groups: [
|
||||
{
|
||||
category: 'Schema',
|
||||
items: [
|
||||
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
|
||||
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
|
||||
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
|
||||
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Statistics',
|
||||
items: [
|
||||
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
|
||||
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
|
||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Core ──
|
||||
{
|
||||
category: 'Statistics',
|
||||
items: [
|
||||
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
|
||||
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
|
||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||
label: 'Core',
|
||||
groups: [
|
||||
{
|
||||
category: 'Tenancy',
|
||||
items: [
|
||||
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||
{ name: 'Merchant domains', sql: "SELECT md.id, m.name AS merchant_name,\n md.domain, md.is_primary, md.is_active,\n md.ssl_status, md.is_verified\nFROM merchant_domains md\nJOIN merchants m ON m.id = md.merchant_id\nORDER BY m.name, md.domain;" },
|
||||
{ name: 'Store domains', sql: "SELECT sd.id, s.name AS store_name,\n sd.domain, sd.is_primary, sd.is_active,\n sd.ssl_status, sd.is_verified\nFROM store_domains sd\nJOIN stores s ON s.id = sd.store_id\nORDER BY s.name, sd.domain;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Permissions',
|
||||
items: [
|
||||
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
|
||||
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
|
||||
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
|
||||
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
|
||||
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Admin & Audit',
|
||||
items: [
|
||||
{ name: 'Audit log', sql: "SELECT al.id, u.email AS admin_email,\n al.action, al.target_type, al.target_id,\n al.ip_address, al.created_at\nFROM admin_audit_logs al\nJOIN users u ON u.id = al.admin_user_id\nORDER BY al.created_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Active sessions', sql: "SELECT s.id, u.email AS admin_email,\n s.ip_address, s.login_at, s.last_activity_at,\n s.is_active, s.logout_reason\nFROM admin_sessions s\nJOIN users u ON u.id = s.admin_user_id\nORDER BY s.last_activity_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin settings', sql: "SELECT id, key, value, value_type,\n category, is_encrypted, is_public\nFROM admin_settings\nORDER BY category, key;" },
|
||||
{ name: 'Platform alerts', sql: "SELECT id, alert_type, severity, title,\n is_resolved, occurrence_count,\n first_occurred_at, last_occurred_at\nFROM platform_alerts\nORDER BY last_occurred_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Application logs', sql: "SELECT id, timestamp, level, logger_name,\n module, message, exception_type,\n request_id\nFROM application_logs\nORDER BY timestamp DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Customers',
|
||||
items: [
|
||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Password reset tokens', sql: "SELECT prt.id, c.email AS customer_email,\n prt.expires_at, prt.used_at, prt.created_at\nFROM password_reset_tokens prt\nJOIN customers c ON c.id = prt.customer_id\nORDER BY prt.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Messaging',
|
||||
items: [
|
||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Conversations', sql: "SELECT cv.id, cv.conversation_type, cv.subject,\n s.name AS store_name, cv.is_closed,\n cv.message_count, cv.last_message_at\nFROM conversations cv\nLEFT JOIN stores s ON s.id = cv.store_id\nORDER BY cv.last_message_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Messages', sql: "SELECT m.id, m.conversation_id,\n m.sender_type, m.sender_id,\n LEFT(m.content, 100) AS content_preview,\n m.is_system_message, m.created_at\nFROM messages m\nORDER BY m.created_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Message attachments', sql: "SELECT ma.id, ma.message_id,\n ma.original_filename, ma.mime_type,\n ma.file_size, ma.is_image, ma.created_at\nFROM message_attachments ma\nORDER BY ma.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Store email templates', sql: "SELECT set_.id, s.name AS store_name,\n set_.template_code, set_.language,\n set_.name, set_.is_active\nFROM store_email_templates set_\nJOIN stores s ON s.id = set_.store_id\nORDER BY s.name, set_.template_code;" },
|
||||
{ name: 'Store email settings', sql: "SELECT ses.id, s.name AS store_name,\n ses.from_email, ses.from_name, ses.provider,\n ses.is_configured, ses.is_verified\nFROM store_email_settings ses\nJOIN stores s ON s.id = ses.store_id\nORDER BY s.name;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'CMS',
|
||||
items: [
|
||||
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Billing',
|
||||
items: [
|
||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||
{ name: 'Tier feature limits', sql: "SELECT tfl.id, st.code AS tier_code,\n st.name AS tier_name, tfl.feature_code,\n tfl.limit_value\nFROM tier_feature_limits tfl\nJOIN subscription_tiers st ON st.id = tfl.tier_id\nORDER BY st.code, tfl.feature_code;" },
|
||||
{ name: 'Merchant feature overrides', sql: "SELECT mfo.id, m.name AS merchant_name,\n p.code AS platform_code, mfo.feature_code,\n mfo.limit_value, mfo.is_enabled, mfo.reason\nFROM merchant_feature_overrides mfo\nJOIN merchants m ON m.id = mfo.merchant_id\nJOIN platforms p ON p.id = mfo.platform_id\nORDER BY m.name, mfo.feature_code;" },
|
||||
{ name: 'Store add-ons', sql: "SELECT sa.id, s.name AS store_name,\n ap.name AS addon_name, sa.status,\n sa.quantity, sa.domain_name,\n sa.period_start, sa.period_end\nFROM store_addons sa\nJOIN stores s ON s.id = sa.store_id\nJOIN addon_products ap ON ap.id = sa.addon_product_id\nORDER BY sa.id DESC;" },
|
||||
{ name: 'Stripe webhook events', sql: "SELECT swe.id, swe.event_id, swe.event_type,\n swe.status, swe.processed_at,\n s.name AS store_name, swe.error_message\nFROM stripe_webhook_events swe\nLEFT JOIN stores s ON s.id = swe.store_id\nORDER BY swe.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── OMS ──
|
||||
{
|
||||
category: 'Tenancy',
|
||||
items: [
|
||||
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
|
||||
label: 'OMS',
|
||||
groups: [
|
||||
{
|
||||
category: 'Orders',
|
||||
items: [
|
||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order item exceptions', sql: "SELECT oie.id, o.order_number,\n oie.original_product_name, oie.original_gtin,\n oie.exception_type, oie.status,\n oie.resolved_at, oie.created_at\nFROM order_item_exceptions oie\nJOIN order_items oi ON oi.id = oie.order_item_id\nJOIN orders o ON o.id = oi.order_id\nORDER BY oie.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Invoice settings', sql: "SELECT sis.id, s.name AS store_name,\n sis.merchant_name, sis.vat_number,\n sis.is_vat_registered, sis.invoice_prefix,\n sis.invoice_next_number, sis.default_vat_rate\nFROM store_invoice_settings sis\nJOIN stores s ON s.id = sis.store_id\nORDER BY s.name;" },
|
||||
{ name: 'Customer order stats', sql: "SELECT cos.id, s.name AS store_name,\n c.email AS customer_email, cos.total_orders,\n cos.total_spent_cents, cos.first_order_date,\n cos.last_order_date\nFROM customer_order_stats cos\nJOIN stores s ON s.id = cos.store_id\nJOIN customers c ON c.id = cos.customer_id\nORDER BY cos.total_spent_cents DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Cart',
|
||||
items: [
|
||||
{ name: 'Cart items', sql: "SELECT ci.id, s.name AS store_name,\n p.store_sku, ci.session_id,\n ci.quantity, ci.price_at_add_cents,\n ci.created_at\nFROM cart_items ci\nJOIN stores s ON s.id = ci.store_id\nJOIN products p ON p.id = ci.product_id\nORDER BY ci.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Catalog',
|
||||
items: [
|
||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Inventory',
|
||||
items: [
|
||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Marketplace',
|
||||
items: [
|
||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Import errors', sql: "SELECT mie.id, mij.marketplace,\n mie.row_number, mie.identifier,\n mie.error_type, mie.error_message\nFROM marketplace_import_errors mie\nJOIN marketplace_import_jobs mij ON mij.id = mie.import_job_id\nORDER BY mie.created_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT mpt.id, mp.gtin,\n mpt.language, mpt.title, mpt.url_slug\nFROM marketplace_product_translations mpt\nJOIN marketplace_products mp ON mp.id = mpt.marketplace_product_id\nORDER BY mpt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Letzshop credentials', sql: "SELECT slc.id, s.name AS store_name,\n slc.api_endpoint, slc.auto_sync_enabled,\n slc.sync_interval_minutes, slc.last_sync_at,\n slc.last_sync_status\nFROM store_letzshop_credentials slc\nJOIN stores s ON s.id = slc.store_id\nORDER BY s.name;" },
|
||||
{ name: 'Sync logs', sql: "SELECT sl.id, s.name AS store_name,\n sl.operation_type, sl.direction, sl.status,\n sl.records_processed, sl.records_failed,\n sl.duration_seconds, sl.triggered_by\nFROM letzshop_sync_logs sl\nJOIN stores s ON s.id = sl.store_id\nORDER BY sl.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Historical import jobs', sql: "SELECT hij.id, s.name AS store_name,\n hij.status, hij.current_phase,\n hij.orders_imported, hij.orders_skipped,\n hij.products_matched, hij.products_not_found\nFROM letzshop_historical_import_jobs hij\nJOIN stores s ON s.id = hij.store_id\nORDER BY hij.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Loyalty ──
|
||||
{
|
||||
category: 'Permissions',
|
||||
items: [
|
||||
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
|
||||
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
|
||||
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
|
||||
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
|
||||
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
|
||||
label: 'Loyalty',
|
||||
groups: [
|
||||
{
|
||||
category: 'Loyalty',
|
||||
items: [
|
||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||
{ name: 'Apple device registrations', sql: "SELECT adr.id, lc.card_number,\n adr.device_library_identifier,\n adr.push_token, adr.created_at\nFROM apple_device_registrations adr\nJOIN loyalty_cards lc ON lc.id = adr.card_id\nORDER BY adr.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Merchant loyalty settings', sql: "SELECT mls.id, m.name AS merchant_name,\n mls.staff_pin_policy,\n mls.allow_self_enrollment,\n mls.allow_void_transactions,\n mls.allow_cross_location_redemption,\n mls.require_order_reference\nFROM merchant_loyalty_settings mls\nJOIN merchants m ON m.id = mls.merchant_id\nORDER BY m.name;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Hosting ──
|
||||
{
|
||||
category: 'System',
|
||||
items: [
|
||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||
label: 'Hosting',
|
||||
groups: [
|
||||
{
|
||||
category: 'Hosting',
|
||||
items: [
|
||||
{ name: 'Hosted sites', sql: "SELECT hs.id, s.name AS store_name,\n hs.business_name, hs.status,\n hs.contact_email, hs.live_domain,\n hs.went_live_at, hs.created_at\nFROM hosted_sites hs\nLEFT JOIN stores s ON s.id = hs.store_id\nORDER BY hs.created_at DESC;" },
|
||||
{ name: 'Client services', sql: "SELECT cs.id, hs.business_name,\n cs.service_type, cs.name, cs.status,\n cs.billing_period, cs.price_cents,\n cs.domain_name, cs.expires_at\nFROM client_services cs\nJOIN hosted_sites hs ON hs.id = cs.hosted_site_id\nORDER BY hs.business_name, cs.service_type;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Prospecting',
|
||||
items: [
|
||||
{ name: 'Prospects', sql: "SELECT id, channel, business_name,\n domain_name, status, source,\n city, country, created_at\nFROM prospects\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Prospect contacts', sql: "SELECT pc.id, p.business_name,\n pc.contact_type, pc.value, pc.label,\n pc.is_primary, pc.is_validated\nFROM prospect_contacts pc\nJOIN prospects p ON p.id = pc.prospect_id\nORDER BY pc.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Tech profiles', sql: "SELECT tp.id, p.business_name,\n tp.cms, tp.cms_version, tp.server,\n tp.hosting_provider, tp.ecommerce_platform,\n tp.has_valid_cert\nFROM prospect_tech_profiles tp\nJOIN prospects p ON p.id = tp.prospect_id\nORDER BY tp.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Performance profiles', sql: "SELECT pp.id, p.business_name,\n pp.performance_score, pp.accessibility_score,\n pp.seo_score, pp.is_mobile_friendly,\n pp.total_bytes, pp.total_requests\nFROM prospect_performance_profiles pp\nJOIN prospects p ON p.id = pp.prospect_id\nORDER BY pp.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Interactions', sql: "SELECT pi.id, p.business_name,\n pi.interaction_type, pi.subject,\n pi.outcome, pi.next_action,\n pi.next_action_date, pi.created_at\nFROM prospect_interactions pi\nJOIN prospects p ON p.id = pi.prospect_id\nORDER BY pi.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Scan jobs', sql: "SELECT id, job_type, status,\n total_items, processed_items, failed_items,\n started_at, completed_at\nFROM prospect_scan_jobs\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Security audits', sql: "SELECT psa.id, p.business_name,\n psa.score, psa.grade,\n psa.findings_count_critical,\n psa.findings_count_high,\n psa.has_https, psa.has_valid_ssl\nFROM prospect_security_audits psa\nJOIN prospects p ON p.id = psa.prospect_id\nORDER BY psa.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Prospect scores', sql: "SELECT ps.id, p.business_name,\n ps.score, ps.lead_tier,\n ps.technical_health_score,\n ps.modernity_score,\n ps.business_value_score,\n ps.engagement_score\nFROM prospect_scores ps\nJOIN prospects p ON p.id = ps.prospect_id\nORDER BY ps.score DESC\nLIMIT 50;" },
|
||||
{ name: 'Campaign templates', sql: "SELECT id, name, lead_type,\n channel, language, is_active\nFROM campaign_templates\nORDER BY lead_type, channel;" },
|
||||
{ name: 'Campaign sends', sql: "SELECT cs.id, ct.name AS template_name,\n p.business_name, cs.channel,\n cs.status, cs.sent_at\nFROM campaign_sends cs\nJOIN campaign_templates ct ON ct.id = cs.template_id\nJOIN prospects p ON p.id = cs.prospect_id\nORDER BY cs.created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// ── Internal ──
|
||||
{
|
||||
category: 'Loyalty',
|
||||
items: [
|
||||
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
|
||||
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
|
||||
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Billing',
|
||||
items: [
|
||||
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
|
||||
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
|
||||
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Orders',
|
||||
items: [
|
||||
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
|
||||
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
|
||||
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Catalog',
|
||||
items: [
|
||||
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Customers',
|
||||
items: [
|
||||
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Inventory',
|
||||
items: [
|
||||
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
|
||||
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'CMS',
|
||||
items: [
|
||||
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
|
||||
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Messaging',
|
||||
items: [
|
||||
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
|
||||
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Marketplace',
|
||||
items: [
|
||||
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
|
||||
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
|
||||
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
|
||||
label: 'Internal',
|
||||
groups: [
|
||||
{
|
||||
category: 'System',
|
||||
items: [
|
||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||
{ name: 'Menu configs', sql: "SELECT amc.id, amc.frontend_type,\n p.code AS platform_code, u.email,\n amc.menu_item_id, amc.is_visible\nFROM admin_menu_configs amc\nLEFT JOIN platforms p ON p.id = amc.platform_id\nLEFT JOIN users u ON u.id = amc.user_id\nORDER BY amc.frontend_type, amc.menu_item_id;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Monitoring',
|
||||
items: [
|
||||
{ name: 'Capacity snapshots', sql: "SELECT id, snapshot_date,\n active_stores, total_products,\n total_orders_month, total_team_members,\n db_size_mb, avg_response_ms,\n peak_cpu_percent, peak_memory_percent\nFROM capacity_snapshots\nORDER BY snapshot_date DESC\nLIMIT 30;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Dev Tools',
|
||||
items: [
|
||||
{ name: 'Test runs', sql: "SELECT id, timestamp, status,\n total_tests, passed, failed, errors,\n coverage_percent, duration_seconds,\n git_branch\nFROM test_runs\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||
{ name: 'Architecture scans', sql: "SELECT id, timestamp, validator_type,\n status, total_files, total_violations,\n errors, warnings, duration_seconds\nFROM architecture_scans\nORDER BY timestamp DESC\nLIMIT 30;" },
|
||||
{ name: 'Architecture violations', sql: "SELECT av.id, av.rule_id, av.rule_name,\n av.severity, av.file_path, av.line_number,\n av.status, av.message\nFROM architecture_violations av\nORDER BY av.created_at DESC\nLIMIT 100;" },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
get filteredPresetSections() {
|
||||
const q = this.presetSearch.toLowerCase().trim();
|
||||
if (!q) return this.presetSections;
|
||||
|
||||
const filtered = [];
|
||||
for (const section of this.presetSections) {
|
||||
const groups = [];
|
||||
for (const group of section.groups) {
|
||||
const items = group.items.filter(
|
||||
item => item.name.toLowerCase().includes(q)
|
||||
|| group.category.toLowerCase().includes(q)
|
||||
|| section.label.toLowerCase().includes(q)
|
||||
);
|
||||
if (items.length > 0) {
|
||||
groups.push({ ...group, items });
|
||||
}
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
filtered.push({ ...section, groups });
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
},
|
||||
|
||||
toggleCategory(category) {
|
||||
this.expandedCategories[category] = !this.expandedCategories[category];
|
||||
},
|
||||
|
||||
isCategoryExpanded(category) {
|
||||
if (this.presetSearch.trim()) return true;
|
||||
return this.expandedCategories[category] || false;
|
||||
},
|
||||
|
||||
|
||||
@@ -24,24 +24,37 @@
|
||||
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<div x-show="showPresets" x-collapse class="mt-3">
|
||||
<template x-for="group in presetQueries" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="preset in group.items" :key="preset.name">
|
||||
<li @click="loadPreset(preset)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="preset.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<!-- Search filter -->
|
||||
<div class="mb-2">
|
||||
<input type="text" x-model="presetSearch" placeholder="Filter presets..."
|
||||
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1.5 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<template x-for="section in filteredPresetSections" :key="section.label">
|
||||
<div class="mb-2">
|
||||
<div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
|
||||
x-text="section.label"></div>
|
||||
<template x-for="group in section.groups" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="preset in group.items" :key="preset.name">
|
||||
<li @click="loadPreset(preset)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="preset.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="presetSearch && filteredPresetSections.length === 0"
|
||||
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
@@ -22,13 +23,90 @@ from app.modules.hosting.schemas.hosted_site import (
|
||||
HostedSiteUpdate,
|
||||
SendProposalRequest,
|
||||
)
|
||||
from app.modules.hosting.schemas.template import TemplateListResponse, TemplateResponse
|
||||
from app.modules.hosting.services.hosted_site_service import hosted_site_service
|
||||
from app.modules.hosting.services.poc_builder_service import poc_builder_service
|
||||
from app.modules.hosting.services.template_service import template_service
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/sites")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=TemplateListResponse)
|
||||
def list_templates(
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List available industry templates for POC site generation."""
|
||||
templates = template_service.list_templates()
|
||||
return TemplateListResponse(
|
||||
templates=[TemplateResponse(**t) for t in templates],
|
||||
)
|
||||
|
||||
|
||||
class PreviewUrlResponse(BaseModel):
|
||||
"""Response with signed preview URL."""
|
||||
|
||||
preview_url: str
|
||||
expires_in_hours: int = 24
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
|
||||
def get_preview_url(
|
||||
site_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Generate a signed preview URL for a hosted site."""
|
||||
from app.core.preview_token import create_preview_token
|
||||
|
||||
site = hosted_site_service.get_by_id(db, site_id)
|
||||
store = site.store
|
||||
subdomain = store.subdomain or store.store_code
|
||||
token = create_preview_token(store.id, subdomain, site.id)
|
||||
return PreviewUrlResponse(
|
||||
preview_url=f"/storefront/{subdomain}/?_preview={token}",
|
||||
)
|
||||
|
||||
|
||||
class BuildPocRequest(BaseModel):
|
||||
"""Request to build a POC site from prospect + template."""
|
||||
|
||||
prospect_id: int
|
||||
template_id: str
|
||||
merchant_id: int | None = None
|
||||
site_id: int | None = None # If set, populate existing site instead of creating new one
|
||||
|
||||
|
||||
class BuildPocResponse(BaseModel):
|
||||
"""Response from POC builder."""
|
||||
|
||||
hosted_site_id: int
|
||||
store_id: int
|
||||
pages_created: int
|
||||
theme_applied: bool
|
||||
template_id: str
|
||||
subdomain: str | None = None
|
||||
|
||||
|
||||
@router.post("/poc/build", response_model=BuildPocResponse)
|
||||
def build_poc(
|
||||
data: BuildPocRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Build a POC site from prospect data + industry template."""
|
||||
result = poc_builder_service.build_poc(
|
||||
db,
|
||||
prospect_id=data.prospect_id,
|
||||
template_id=data.template_id,
|
||||
merchant_id=data.merchant_id,
|
||||
site_id=data.site_id,
|
||||
)
|
||||
db.commit()
|
||||
return BuildPocResponse(**result)
|
||||
|
||||
|
||||
def _to_response(site) -> HostedSiteResponse:
|
||||
"""Convert a hosted site model to response schema."""
|
||||
return HostedSiteResponse(
|
||||
@@ -96,17 +174,6 @@ def create_site(
|
||||
return _to_response(site)
|
||||
|
||||
|
||||
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
|
||||
def create_from_prospect(
|
||||
prospect_id: int = Path(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create a hosted site pre-filled from prospect data."""
|
||||
site = hosted_site_service.create_from_prospect(db, prospect_id)
|
||||
db.commit()
|
||||
return _to_response(site)
|
||||
|
||||
|
||||
@router.put("/{site_id}", response_model=HostedSiteResponse)
|
||||
def update_site(
|
||||
|
||||
@@ -2,45 +2,73 @@
|
||||
"""
|
||||
Hosting Public Page Routes.
|
||||
|
||||
Public-facing routes for POC site viewing:
|
||||
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
|
||||
POC site preview via signed URL redirect to the storefront.
|
||||
The StorefrontAccessMiddleware validates the preview token and
|
||||
allows rendering without an active subscription.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.templates_config import templates
|
||||
from app.core.preview_token import create_preview_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/hosting/sites/{site_id}/preview",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def poc_site_viewer(
|
||||
request: Request,
|
||||
site_id: int = Path(..., description="Hosted Site ID"),
|
||||
page: str = Query("homepage", description="Page slug to preview"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Render POC site viewer with HostWizard preview banner."""
|
||||
"""Redirect to storefront with signed preview token.
|
||||
|
||||
Generates a time-limited JWT and redirects to the store's
|
||||
storefront URL. The StorefrontAccessMiddleware validates the
|
||||
token and bypasses the subscription check.
|
||||
"""
|
||||
from app.modules.hosting.models import HostedSite, HostedSiteStatus
|
||||
|
||||
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
|
||||
|
||||
# Only allow viewing for poc_ready or proposal_sent sites
|
||||
if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
|
||||
if not site or site.status not in (
|
||||
HostedSiteStatus.POC_READY,
|
||||
HostedSiteStatus.PROPOSAL_SENT,
|
||||
HostedSiteStatus.ACCEPTED,
|
||||
):
|
||||
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"site": site,
|
||||
"store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
"hosting/public/poc-viewer.html",
|
||||
context,
|
||||
store = site.store
|
||||
if not store:
|
||||
return HTMLResponse(content="<h1>Store not found</h1>", status_code=404)
|
||||
|
||||
# Generate signed preview token — use subdomain for URL routing
|
||||
subdomain = store.subdomain or store.store_code
|
||||
token = create_preview_token(store.id, subdomain, site.id)
|
||||
|
||||
# Get platform code for dev-mode URL prefix
|
||||
from app.core.config import settings
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
|
||||
store_platform = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == store.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# In dev mode, storefront needs /platforms/{code}/ prefix
|
||||
if settings.debug and store_platform and store_platform.platform:
|
||||
platform_code = store_platform.platform.code
|
||||
base_url = f"/platforms/{platform_code}/storefront/{subdomain}"
|
||||
else:
|
||||
base_url = f"/storefront/{subdomain}"
|
||||
|
||||
# Append page slug — storefront needs /{slug} (root has no catch-all)
|
||||
base_url += f"/{page}"
|
||||
|
||||
return RedirectResponse(f"{base_url}?_preview={token}", status_code=302)
|
||||
|
||||
@@ -3,18 +3,31 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class HostedSiteCreate(BaseModel):
|
||||
"""Schema for creating a hosted site."""
|
||||
"""Schema for creating a hosted site.
|
||||
|
||||
Either merchant_id or prospect_id must be provided:
|
||||
- merchant_id: store is created under this merchant
|
||||
- prospect_id: a merchant is auto-created from prospect data
|
||||
"""
|
||||
|
||||
business_name: str = Field(..., max_length=255)
|
||||
merchant_id: int | None = None
|
||||
prospect_id: int | None = None
|
||||
contact_name: str | None = Field(None, max_length=255)
|
||||
contact_email: str | None = Field(None, max_length=255)
|
||||
contact_phone: str | None = Field(None, max_length=50)
|
||||
internal_notes: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_merchant_or_prospect(self) -> "HostedSiteCreate":
|
||||
if not self.merchant_id and not self.prospect_id:
|
||||
raise ValueError("Either merchant_id or prospect_id is required")
|
||||
return self
|
||||
|
||||
|
||||
class HostedSiteUpdate(BaseModel):
|
||||
"""Schema for updating a hosted site."""
|
||||
|
||||
21
app/modules/hosting/schemas/template.py
Normal file
21
app/modules/hosting/schemas/template.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# app/modules/hosting/schemas/template.py
|
||||
"""Pydantic schemas for template responses."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""Schema for a single template."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str] = []
|
||||
languages: list[str] = []
|
||||
pages: list[str] = []
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
"""Schema for template list response."""
|
||||
|
||||
templates: list[TemplateResponse]
|
||||
@@ -34,12 +34,30 @@ ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = {
|
||||
}
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Generate a URL-safe slug from a business name."""
|
||||
def _slugify(name: str, max_length: int = 30) -> str:
|
||||
"""Generate a short URL-safe slug from a domain or business name.
|
||||
|
||||
Priority: domain name (clean) > first 3 words of business name > full slug truncated.
|
||||
"""
|
||||
slug = name.lower().strip()
|
||||
# If it looks like a domain, extract the hostname part
|
||||
for prefix in ["https://", "http://", "www."]:
|
||||
if slug.startswith(prefix):
|
||||
slug = slug[len(prefix):]
|
||||
slug = slug.rstrip("/")
|
||||
if "." in slug and " " not in slug:
|
||||
# Domain: remove TLD → batirenovation-strasbourg.fr → batirenovation-strasbourg
|
||||
slug = slug.rsplit(".", 1)[0]
|
||||
else:
|
||||
# Business name: take first 3 meaningful words for brevity
|
||||
words = re.sub(r"[^a-z0-9\s]", "", slug).split()
|
||||
# Skip filler words
|
||||
filler = {"the", "le", "la", "les", "de", "du", "des", "et", "and", "und", "die", "der", "das"}
|
||||
words = [w for w in words if w not in filler][:3]
|
||||
slug = " ".join(words)
|
||||
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
|
||||
slug = re.sub(r"[\s-]+", "-", slug)
|
||||
return slug.strip("-")[:50]
|
||||
return slug.strip("-")[:max_length]
|
||||
|
||||
|
||||
class HostedSiteService:
|
||||
@@ -88,50 +106,47 @@ class HostedSiteService:
|
||||
return sites, total
|
||||
|
||||
def create(self, db: Session, data: dict) -> HostedSite:
|
||||
"""Create a hosted site with an auto-created Store on the hosting platform."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
"""Create a hosted site with an auto-created Store on the hosting platform.
|
||||
|
||||
Requires either merchant_id or prospect_id in data:
|
||||
- merchant_id: store created under this merchant
|
||||
- prospect_id: merchant auto-created from prospect data
|
||||
"""
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store
|
||||
from app.modules.tenancy.schemas.store import StoreCreate
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
|
||||
business_name = data["business_name"]
|
||||
slug = _slugify(business_name)
|
||||
merchant_id = data.get("merchant_id")
|
||||
prospect_id = data.get("prospect_id")
|
||||
# Prefer domain_name for slug (shorter, cleaner), fall back to business_name
|
||||
slug_source = data.get("domain_name") or business_name
|
||||
slug = _slugify(slug_source)
|
||||
|
||||
# Find hosting platform
|
||||
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
||||
if not platform:
|
||||
raise ValueError("Hosting platform not found. Run init_production first.")
|
||||
|
||||
# Create a temporary merchant-less store requires a merchant_id.
|
||||
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
|
||||
# Use the platform's own admin store or create under a system merchant.
|
||||
# For now, create store via AdminService which handles defaults.
|
||||
store_code = slug.upper().replace("-", "_")[:50]
|
||||
subdomain = slug
|
||||
# Resolve merchant
|
||||
if merchant_id:
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise ValueError(f"Merchant {merchant_id} not found")
|
||||
elif prospect_id:
|
||||
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
|
||||
else:
|
||||
raise ValueError("Either merchant_id or prospect_id is required")
|
||||
|
||||
# Check for duplicate subdomain
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
subdomain = slug
|
||||
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
|
||||
if existing:
|
||||
raise DuplicateSlugException(subdomain)
|
||||
|
||||
# We need a system merchant for POC sites.
|
||||
# Look for one or create if needed.
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
|
||||
if not system_merchant:
|
||||
system_merchant = Merchant(
|
||||
name="HostWizard System",
|
||||
contact_email="system@hostwizard.lu",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(system_merchant)
|
||||
db.flush()
|
||||
|
||||
store_code = slug.upper().replace("-", "_")[:50]
|
||||
store_data = StoreCreate(
|
||||
merchant_id=system_merchant.id,
|
||||
merchant_id=merchant.id,
|
||||
store_code=store_code,
|
||||
subdomain=subdomain,
|
||||
name=business_name,
|
||||
@@ -142,7 +157,7 @@ class HostedSiteService:
|
||||
|
||||
site = HostedSite(
|
||||
store_id=store.id,
|
||||
prospect_id=data.get("prospect_id"),
|
||||
prospect_id=prospect_id,
|
||||
status=HostedSiteStatus.DRAFT,
|
||||
business_name=business_name,
|
||||
contact_name=data.get("contact_name"),
|
||||
@@ -153,12 +168,14 @@ class HostedSiteService:
|
||||
db.add(site)
|
||||
db.flush()
|
||||
|
||||
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
|
||||
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
|
||||
return site
|
||||
|
||||
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
|
||||
"""Create a hosted site pre-filled from prospect data."""
|
||||
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
|
||||
"""Create a merchant from prospect data."""
|
||||
from app.modules.prospecting.models import Prospect
|
||||
from app.modules.tenancy.schemas.merchant import MerchantCreate
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
|
||||
if not prospect:
|
||||
@@ -166,20 +183,29 @@ class HostedSiteService:
|
||||
|
||||
raise ProspectNotFoundException(str(prospect_id))
|
||||
|
||||
# Get primary contact info from prospect contacts
|
||||
# Get contact info: prefer form data, fall back to prospect contacts
|
||||
contacts = prospect.contacts or []
|
||||
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
|
||||
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
|
||||
contact_name = next((c.label for c in contacts if c.label), None)
|
||||
email = (
|
||||
data.get("contact_email")
|
||||
or next((c.value for c in contacts if c.contact_type == "email"), None)
|
||||
or f"contact-{prospect_id}@hostwizard.lu"
|
||||
)
|
||||
phone = data.get("contact_phone") or next(
|
||||
(c.value for c in contacts if c.contact_type == "phone"), None
|
||||
)
|
||||
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
|
||||
|
||||
data = {
|
||||
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
|
||||
"contact_name": contact_name,
|
||||
"contact_email": primary_email,
|
||||
"contact_phone": primary_phone,
|
||||
"prospect_id": prospect.id,
|
||||
}
|
||||
return self.create(db, data)
|
||||
merchant_data = MerchantCreate(
|
||||
name=business_name,
|
||||
contact_email=email,
|
||||
contact_phone=phone,
|
||||
owner_email=email,
|
||||
)
|
||||
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
|
||||
db, merchant_data
|
||||
)
|
||||
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
|
||||
return merchant
|
||||
|
||||
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
|
||||
site = self.get_by_id(db, site_id)
|
||||
@@ -192,8 +218,19 @@ class HostedSiteService:
|
||||
return site
|
||||
|
||||
def delete(self, db: Session, site_id: int) -> bool:
|
||||
"""Delete a hosted site and soft-delete the associated store."""
|
||||
from app.core.soft_delete import soft_delete
|
||||
|
||||
site = self.get_by_id(db, site_id)
|
||||
store = site.store
|
||||
|
||||
db.delete(site)
|
||||
|
||||
# Soft-delete the store created for this site (frees the subdomain)
|
||||
if store:
|
||||
soft_delete(db, store)
|
||||
logger.info("Soft-deleted store %d (subdomain=%s) for site %d", store.id, store.subdomain, site_id)
|
||||
|
||||
db.flush()
|
||||
logger.info("Deleted hosted site: %d", site_id)
|
||||
return True
|
||||
@@ -227,37 +264,25 @@ class HostedSiteService:
|
||||
def accept_proposal(
|
||||
self, db: Session, site_id: int, merchant_id: int | None = None
|
||||
) -> HostedSite:
|
||||
"""Accept proposal: create or link merchant, create subscription, mark converted."""
|
||||
"""Accept proposal: create subscription, mark prospect converted.
|
||||
|
||||
The merchant already exists (assigned at site creation time).
|
||||
Optionally pass merchant_id to reassign to a different merchant.
|
||||
"""
|
||||
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
|
||||
site.proposal_accepted_at = datetime.now(UTC)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Platform
|
||||
|
||||
# Use provided merchant_id to reassign, or keep existing store merchant
|
||||
if merchant_id:
|
||||
# Link to existing merchant
|
||||
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||
if not merchant:
|
||||
raise ValueError(f"Merchant {merchant_id} not found")
|
||||
site.store.merchant_id = merchant.id
|
||||
db.flush()
|
||||
else:
|
||||
# Create new merchant from contact info
|
||||
from app.modules.tenancy.schemas.merchant import MerchantCreate
|
||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||
|
||||
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
|
||||
merchant_data = MerchantCreate(
|
||||
name=site.business_name,
|
||||
contact_email=email,
|
||||
contact_phone=site.contact_phone,
|
||||
owner_email=email,
|
||||
)
|
||||
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
|
||||
db, merchant_data
|
||||
)
|
||||
logger.info("Created merchant %s for site %d", merchant.name, site_id)
|
||||
|
||||
# Re-assign store to the real merchant
|
||||
site.store.merchant_id = merchant.id
|
||||
db.flush()
|
||||
merchant = site.store.merchant
|
||||
|
||||
# Create MerchantSubscription on hosting platform
|
||||
platform = db.query(Platform).filter(Platform.code == "hosting").first()
|
||||
@@ -286,7 +311,6 @@ class HostedSiteService:
|
||||
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
|
||||
if prospect and prospect.status != ProspectStatus.CONVERTED:
|
||||
prospect.status = ProspectStatus.CONVERTED
|
||||
db.flush()
|
||||
|
||||
db.flush()
|
||||
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)
|
||||
|
||||
277
app/modules/hosting/services/poc_builder_service.py
Normal file
277
app/modules/hosting/services/poc_builder_service.py
Normal file
@@ -0,0 +1,277 @@
|
||||
# app/modules/hosting/services/poc_builder_service.py
|
||||
"""
|
||||
POC Builder Service — creates a near-final multi-page website from
|
||||
a prospect + industry template.
|
||||
|
||||
Flow:
|
||||
1. Load prospect data (scraped content, contacts)
|
||||
2. Load industry template (pages, theme)
|
||||
3. Create HostedSite + Store via hosted_site_service
|
||||
4. Populate CMS ContentPages from template, replacing {{placeholders}}
|
||||
with prospect data
|
||||
5. Apply StoreTheme from template
|
||||
6. Result: a previewable site at {subdomain}.hostwizard.lu
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.hosting.services.hosted_site_service import hosted_site_service
|
||||
from app.modules.hosting.services.template_service import template_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PocBuilderService:
|
||||
"""Builds POC sites from prospect data + industry templates."""
|
||||
|
||||
def build_poc(
|
||||
self,
|
||||
db: Session,
|
||||
prospect_id: int,
|
||||
template_id: str,
|
||||
merchant_id: int | None = None,
|
||||
site_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Build a complete POC site from prospect data and a template.
|
||||
|
||||
If site_id is given, populates the existing site's store with CMS
|
||||
content. Otherwise creates a new HostedSite + Store.
|
||||
|
||||
Returns dict with hosted_site, store, pages_created, theme_applied.
|
||||
"""
|
||||
from app.modules.prospecting.models import Prospect
|
||||
|
||||
# 1. Load prospect
|
||||
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
|
||||
if not prospect:
|
||||
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
||||
|
||||
raise ProspectNotFoundException(str(prospect_id))
|
||||
|
||||
# 2. Load template
|
||||
template = template_service.get_template(template_id)
|
||||
if not template:
|
||||
raise ValueError(f"Template '{template_id}' not found")
|
||||
|
||||
# 3. Build placeholder context from prospect data
|
||||
context = self._build_context(prospect)
|
||||
|
||||
# 4. Use existing site or create new one
|
||||
if site_id:
|
||||
site = hosted_site_service.get_by_id(db, site_id)
|
||||
else:
|
||||
site_data = {
|
||||
"business_name": context["business_name"],
|
||||
"domain_name": prospect.domain_name,
|
||||
"prospect_id": prospect_id,
|
||||
"contact_email": context.get("email"),
|
||||
"contact_phone": context.get("phone"),
|
||||
}
|
||||
if merchant_id:
|
||||
site_data["merchant_id"] = merchant_id
|
||||
site = hosted_site_service.create(db, site_data)
|
||||
|
||||
# 5. Get the hosting platform_id from the store
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
|
||||
store_platform = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == site.store_id)
|
||||
.first()
|
||||
)
|
||||
platform_id = store_platform.platform_id if store_platform else None
|
||||
|
||||
if not platform_id:
|
||||
logger.warning("No platform found for store %d", site.store_id)
|
||||
|
||||
# 6. Populate CMS ContentPages from template
|
||||
pages_created = 0
|
||||
if platform_id:
|
||||
pages_created = self._create_pages(db, site.store_id, platform_id, template, context)
|
||||
|
||||
# 7. Apply StoreTheme
|
||||
theme_applied = self._apply_theme(db, site.store_id, template)
|
||||
|
||||
# 8. Mark POC ready
|
||||
hosted_site_service.mark_poc_ready(db, site.id)
|
||||
|
||||
db.flush()
|
||||
logger.info(
|
||||
"POC built for prospect %d: site=%d, store=%d, %d pages, template=%s",
|
||||
prospect_id, site.id, site.store_id, pages_created, template_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"hosted_site_id": site.id,
|
||||
"store_id": site.store_id,
|
||||
"pages_created": pages_created,
|
||||
"theme_applied": theme_applied,
|
||||
"template_id": template_id,
|
||||
"subdomain": site.store.subdomain if site.store else None,
|
||||
}
|
||||
|
||||
def _build_context(self, prospect) -> dict:
|
||||
"""Build placeholder replacement context from prospect data."""
|
||||
# Base context
|
||||
context = {
|
||||
"business_name": prospect.business_name or prospect.domain_name or "My Business",
|
||||
"domain": prospect.domain_name or "",
|
||||
"city": prospect.city or "",
|
||||
"address": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"meta_description": "",
|
||||
"about_paragraph": "",
|
||||
}
|
||||
|
||||
# From contacts
|
||||
contacts = prospect.contacts or []
|
||||
for c in contacts:
|
||||
if c.contact_type == "email" and not context["email"]:
|
||||
context["email"] = c.value
|
||||
elif c.contact_type == "phone" and not context["phone"]:
|
||||
context["phone"] = c.value
|
||||
elif c.contact_type == "address" and not context["address"]:
|
||||
context["address"] = c.value
|
||||
|
||||
# From scraped content
|
||||
if prospect.scraped_content_json:
|
||||
try:
|
||||
scraped = json.loads(prospect.scraped_content_json)
|
||||
if scraped.get("meta_description"):
|
||||
context["meta_description"] = scraped["meta_description"]
|
||||
if scraped.get("paragraphs"):
|
||||
context["about_paragraph"] = scraped["paragraphs"][0]
|
||||
# Build rich content from scraped paragraphs for page bodies
|
||||
context["scraped_paragraphs_html"] = "\n".join(
|
||||
f"<p>{p}</p>" for p in scraped["paragraphs"][:5]
|
||||
)
|
||||
if scraped.get("headings"):
|
||||
if not prospect.business_name:
|
||||
context["business_name"] = scraped["headings"][0]
|
||||
# Use second heading as tagline if available
|
||||
if len(scraped["headings"]) > 1:
|
||||
context["tagline"] = scraped["headings"][1]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# From prospect fields
|
||||
if prospect.city:
|
||||
context["city"] = prospect.city
|
||||
elif context["address"]:
|
||||
# Try to extract city from address (last word after postal code)
|
||||
parts = context["address"].split()
|
||||
if len(parts) >= 2:
|
||||
context["city"] = parts[-1]
|
||||
|
||||
return context
|
||||
|
||||
def _replace_placeholders(self, text: str, context: dict) -> str:
|
||||
"""Replace {{placeholder}} variables in text with context values."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
def replacer(match):
|
||||
key = match.group(1).strip()
|
||||
return context.get(key, match.group(0))
|
||||
|
||||
return re.sub(r"\{\{(\w+)\}\}", replacer, text)
|
||||
|
||||
def _replace_in_structure(self, data, context: dict):
|
||||
"""Recursively replace placeholders in a nested dict/list structure."""
|
||||
if isinstance(data, str):
|
||||
return self._replace_placeholders(data, context)
|
||||
if isinstance(data, dict):
|
||||
return {k: self._replace_in_structure(v, context) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [self._replace_in_structure(item, context) for item in data]
|
||||
return data
|
||||
|
||||
def _create_pages(self, db: Session, store_id: int, platform_id: int, template: dict, context: dict) -> int:
|
||||
"""Create CMS ContentPages from template page definitions."""
|
||||
from app.modules.cms.models.content_page import ContentPage
|
||||
|
||||
count = 0
|
||||
for page_def in template.get("pages", []):
|
||||
slug = page_def.get("slug", "")
|
||||
if not slug:
|
||||
continue
|
||||
|
||||
# Replace placeholders in all text fields
|
||||
page_data = self._replace_in_structure(page_def, context)
|
||||
|
||||
# Build content from content_translations if present
|
||||
content = page_data.get("content", "")
|
||||
content_translations = page_data.get("content_translations")
|
||||
if content_translations and not content:
|
||||
content = next(iter(content_translations.values()), "")
|
||||
|
||||
# Enrich with scraped paragraphs (append to template content)
|
||||
scraped_html = context.get("scraped_paragraphs_html", "")
|
||||
if scraped_html and slug in ("about", "services", "projects"):
|
||||
content = content + "\n" + scraped_html if content else scraped_html
|
||||
if content_translations:
|
||||
for lang_code in content_translations:
|
||||
content_translations[lang_code] = (
|
||||
content_translations[lang_code] + "\n" + scraped_html
|
||||
)
|
||||
|
||||
page = ContentPage(
|
||||
platform_id=platform_id,
|
||||
store_id=store_id,
|
||||
is_platform_page=False,
|
||||
slug=slug,
|
||||
title=page_data.get("title", slug.title()),
|
||||
content=content or f"<p>{slug.title()} page content</p>",
|
||||
content_format="html",
|
||||
template=page_data.get("template", "default"),
|
||||
sections=page_data.get("sections"),
|
||||
title_translations=page_data.get("title_translations"),
|
||||
content_translations=content_translations,
|
||||
meta_description=context.get("meta_description"),
|
||||
is_published=page_data.get("is_published", True),
|
||||
published_at=datetime.now(UTC) if page_data.get("is_published", True) else None,
|
||||
show_in_header=page_data.get("show_in_header", False),
|
||||
show_in_footer=page_data.get("show_in_footer", False),
|
||||
)
|
||||
db.add(page)
|
||||
count += 1
|
||||
|
||||
db.flush()
|
||||
return count
|
||||
|
||||
def _apply_theme(self, db: Session, store_id: int, template: dict) -> bool:
|
||||
"""Apply the template's theme to the store."""
|
||||
from app.modules.cms.models.store_theme import StoreTheme
|
||||
|
||||
theme_data = template.get("theme")
|
||||
if not theme_data:
|
||||
return False
|
||||
|
||||
# Check if store already has a theme
|
||||
existing = db.query(StoreTheme).filter(StoreTheme.store_id == store_id).first()
|
||||
if existing:
|
||||
# Update existing
|
||||
theme = existing
|
||||
else:
|
||||
theme = StoreTheme(store_id=store_id)
|
||||
db.add(theme)
|
||||
|
||||
colors = theme_data.get("colors", {})
|
||||
theme.theme_name = theme_data.get("theme_name", "default")
|
||||
theme.colors = colors
|
||||
theme.font_family_heading = theme_data.get("font_family_heading", "Inter")
|
||||
theme.font_family_body = theme_data.get("font_family_body", "Inter")
|
||||
theme.layout_style = theme_data.get("layout_style", "grid")
|
||||
theme.header_style = theme_data.get("header_style", "fixed")
|
||||
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
poc_builder_service = PocBuilderService()
|
||||
114
app/modules/hosting/services/template_service.py
Normal file
114
app/modules/hosting/services/template_service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# app/modules/hosting/services/template_service.py
|
||||
"""
|
||||
Template service for the hosting module.
|
||||
|
||||
Loads and manages industry templates from the templates_library directory.
|
||||
Templates are JSON files that define page content, themes, and sections
|
||||
for different business types (restaurant, construction, etc.).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates_library"
|
||||
|
||||
|
||||
class TemplateService:
|
||||
"""Manages industry templates for POC site generation."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = None
|
||||
self._cache: dict[str, dict] = {}
|
||||
|
||||
def _load_manifest(self) -> dict:
|
||||
"""Load the manifest.json file."""
|
||||
if self._manifest is None:
|
||||
manifest_path = TEMPLATES_DIR / "manifest.json"
|
||||
self._manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
return self._manifest
|
||||
|
||||
def list_templates(self) -> list[dict]:
|
||||
"""List all available templates with metadata."""
|
||||
manifest = self._load_manifest()
|
||||
templates = []
|
||||
for entry in manifest.get("templates", []):
|
||||
template_id = entry["id"]
|
||||
meta = self._load_meta(template_id)
|
||||
templates.append({
|
||||
"id": template_id,
|
||||
"name": meta.get("name", entry.get("name", template_id)),
|
||||
"description": meta.get("description", entry.get("description", "")),
|
||||
"tags": meta.get("tags", entry.get("tags", [])),
|
||||
"languages": meta.get("languages", []),
|
||||
"pages": entry.get("pages", []),
|
||||
})
|
||||
return templates
|
||||
|
||||
def get_template(self, template_id: str) -> dict | None:
|
||||
"""Load a complete template with meta, theme, and all pages."""
|
||||
if template_id in self._cache:
|
||||
return self._cache[template_id]
|
||||
|
||||
template_dir = TEMPLATES_DIR / template_id
|
||||
if not template_dir.is_dir():
|
||||
return None
|
||||
|
||||
meta = self._load_meta(template_id)
|
||||
theme = self._load_json(template_dir / "theme.json")
|
||||
pages = self._load_pages(template_dir)
|
||||
|
||||
template = {
|
||||
"id": template_id,
|
||||
"meta": meta,
|
||||
"theme": theme,
|
||||
"pages": pages,
|
||||
}
|
||||
self._cache[template_id] = template
|
||||
return template
|
||||
|
||||
def get_theme(self, template_id: str) -> dict | None:
|
||||
"""Load just the theme configuration for a template."""
|
||||
template_dir = TEMPLATES_DIR / template_id
|
||||
return self._load_json(template_dir / "theme.json")
|
||||
|
||||
def get_page(self, template_id: str, page_slug: str) -> dict | None:
|
||||
"""Load a single page definition from a template."""
|
||||
page_path = TEMPLATES_DIR / template_id / "pages" / f"{page_slug}.json"
|
||||
return self._load_json(page_path)
|
||||
|
||||
def template_exists(self, template_id: str) -> bool:
|
||||
"""Check if a template exists."""
|
||||
return (TEMPLATES_DIR / template_id / "meta.json").is_file()
|
||||
|
||||
def _load_meta(self, template_id: str) -> dict:
|
||||
"""Load meta.json for a template."""
|
||||
return self._load_json(TEMPLATES_DIR / template_id / "meta.json") or {}
|
||||
|
||||
def _load_pages(self, template_dir: Path) -> list[dict]:
|
||||
"""Load all page JSONs from a template's pages/ directory."""
|
||||
pages_dir = template_dir / "pages"
|
||||
if not pages_dir.is_dir():
|
||||
return []
|
||||
pages = []
|
||||
for page_file in sorted(pages_dir.glob("*.json")):
|
||||
page_data = self._load_json(page_file)
|
||||
if page_data:
|
||||
pages.append(page_data)
|
||||
return pages
|
||||
|
||||
@staticmethod
|
||||
def _load_json(path: Path) -> dict | None:
|
||||
"""Safely load a JSON file."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load template file %s: %s", path, e)
|
||||
return None
|
||||
|
||||
|
||||
template_service = TemplateService()
|
||||
@@ -12,7 +12,8 @@
|
||||
{{ loading_state('Loading site...') }}
|
||||
{{ error_state('Error loading site') }}
|
||||
|
||||
<div x-show="!loading && !error && site" class="space-y-6">
|
||||
<template x-if="!loading && !error && site">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -38,6 +39,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build POC (draft sites only) -->
|
||||
<div x-show="site.status === 'draft' && site.prospect_id" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Build POC from Template</h3>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<select x-model="selectedTemplate"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Select a template...</option>
|
||||
<template x-for="t in templates" :key="t.id">
|
||||
<option :value="t.id" x-text="t.name + ' — ' + t.description"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" @click="buildPoc()" :disabled="!selectedTemplate || buildingPoc"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
|
||||
<span x-text="buildingPoc ? 'Building...' : 'Build POC'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="pocResult" class="mt-2 text-sm text-green-600" x-text="pocResult"></p>
|
||||
</div>
|
||||
|
||||
<!-- Lifecycle Actions -->
|
||||
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<button type="button" x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
|
||||
@@ -187,6 +209,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Send Proposal Modal -->
|
||||
{% call modal('proposalModal', 'Send Proposal', show_var='showProposalModal', size='md', show_footer=false) %}
|
||||
@@ -305,7 +328,40 @@ function hostingSiteDetail(siteId) {
|
||||
acceptMerchantId: '',
|
||||
goLiveDomain: '',
|
||||
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
|
||||
async init() { await this.loadSite(); },
|
||||
// POC builder
|
||||
templates: [],
|
||||
selectedTemplate: '',
|
||||
buildingPoc: false,
|
||||
pocResult: '',
|
||||
async init() {
|
||||
await this.loadSite();
|
||||
await this.loadTemplates();
|
||||
},
|
||||
async loadTemplates() {
|
||||
try {
|
||||
var resp = await apiClient.get('/admin/hosting/sites/templates');
|
||||
this.templates = resp.templates || [];
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
async buildPoc() {
|
||||
if (!this.selectedTemplate || !this.site.prospect_id) return;
|
||||
this.buildingPoc = true;
|
||||
this.pocResult = '';
|
||||
try {
|
||||
var result = await apiClient.post('/admin/hosting/sites/poc/build', {
|
||||
prospect_id: this.site.prospect_id,
|
||||
template_id: this.selectedTemplate,
|
||||
site_id: this.site.id,
|
||||
});
|
||||
this.pocResult = 'POC built! ' + result.pages_created + ' pages created.';
|
||||
Utils.showToast('POC built successfully', 'success');
|
||||
await this.loadSite();
|
||||
} catch (e) {
|
||||
Utils.showToast('Build failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.buildingPoc = false;
|
||||
}
|
||||
},
|
||||
async loadSite() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -44,18 +44,48 @@
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Prospect Selector -->
|
||||
<!-- Prospect Search -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Create from Prospect (optional)</label>
|
||||
<div class="flex mt-1 space-x-2">
|
||||
<input type="number" x-model="prospectId" placeholder="Prospect ID" {# noqa: FE008 - prospect ID input #}
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
|
||||
<button type="button" @click="createFromProspect()"
|
||||
:disabled="!prospectId || creating"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Create from Prospect
|
||||
</button>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Link to Prospect <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text" x-model="prospectSearch" @input.debounce.300ms="searchProspects()"
|
||||
@focus="showProspectDropdown = true"
|
||||
placeholder="Search by domain or business name..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<!-- Selected prospect badge -->
|
||||
<div x-show="selectedProspect" class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300">
|
||||
<span x-text="'#' + form.prospect_id"></span>
|
||||
<button type="button" @click="clearProspect()" class="ml-1 text-teal-500 hover:text-teal-700">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Dropdown -->
|
||||
<div x-show="showProspectDropdown && prospectResults.length > 0" @click.away="showProspectDropdown = false"
|
||||
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-auto">
|
||||
<template x-for="p in prospectResults" :key="p.id">
|
||||
<button type="button" @click="selectProspect(p)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200" x-text="p.business_name || p.domain_name"></span>
|
||||
<span x-show="p.domain_name && p.business_name" class="text-xs text-gray-400 ml-2" x-text="p.domain_name"></span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400" x-text="'#' + p.id"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">A merchant will be auto-created from the prospect's contact data.</p>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Existing Merchant -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Or link to existing Merchant ID <span class="text-xs text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input type="number" x-model.number="form.merchant_id" placeholder="Leave empty to auto-create" {# noqa: FE008 #}
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,10 +96,8 @@
|
||||
Cancel
|
||||
</a>
|
||||
<button type="button" @click="createSite()"
|
||||
:disabled="!form.business_name || creating"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
:disabled="!canCreate || creating"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-text="creating ? 'Creating...' : 'Create Site'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,15 +113,76 @@ function hostingSiteNew() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-sites',
|
||||
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
|
||||
prospectId: '',
|
||||
form: {
|
||||
business_name: '',
|
||||
prospect_id: null,
|
||||
merchant_id: null,
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
internal_notes: '',
|
||||
},
|
||||
// Prospect search
|
||||
prospectSearch: '',
|
||||
prospectResults: [],
|
||||
selectedProspect: null,
|
||||
showProspectDropdown: false,
|
||||
|
||||
creating: false,
|
||||
errorMsg: '',
|
||||
|
||||
get canCreate() {
|
||||
return this.form.business_name && (this.form.prospect_id || this.form.merchant_id);
|
||||
},
|
||||
|
||||
async searchProspects() {
|
||||
if (this.prospectSearch.length < 2) { this.prospectResults = []; return; }
|
||||
try {
|
||||
var resp = await apiClient.get('/admin/prospecting/prospects?search=' + encodeURIComponent(this.prospectSearch) + '&per_page=10');
|
||||
this.prospectResults = resp.items || [];
|
||||
this.showProspectDropdown = true;
|
||||
} catch (e) {
|
||||
this.prospectResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
selectProspect(prospect) {
|
||||
this.selectedProspect = prospect;
|
||||
this.form.prospect_id = prospect.id;
|
||||
this.prospectSearch = prospect.business_name || prospect.domain_name;
|
||||
this.showProspectDropdown = false;
|
||||
// Auto-fill form from prospect
|
||||
if (!this.form.business_name) {
|
||||
this.form.business_name = prospect.business_name || prospect.domain_name || '';
|
||||
}
|
||||
if (!this.form.contact_email && prospect.primary_email) {
|
||||
this.form.contact_email = prospect.primary_email;
|
||||
}
|
||||
if (!this.form.contact_phone && prospect.primary_phone) {
|
||||
this.form.contact_phone = prospect.primary_phone;
|
||||
}
|
||||
},
|
||||
|
||||
clearProspect() {
|
||||
this.selectedProspect = null;
|
||||
this.form.prospect_id = null;
|
||||
this.prospectSearch = '';
|
||||
this.prospectResults = [];
|
||||
},
|
||||
|
||||
async createSite() {
|
||||
if (!this.canCreate) {
|
||||
this.errorMsg = 'Business name and a linked prospect or merchant are required';
|
||||
return;
|
||||
}
|
||||
this.creating = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const site = await apiClient.post('/admin/hosting/sites', this.form);
|
||||
var payload = {};
|
||||
for (var k in this.form) {
|
||||
if (this.form[k] !== null && this.form[k] !== '') payload[k] = this.form[k];
|
||||
}
|
||||
const site = await apiClient.post('/admin/hosting/sites', payload);
|
||||
window.location.href = '/admin/hosting/sites/' + site.id;
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message || 'Failed to create site';
|
||||
@@ -101,18 +190,6 @@ function hostingSiteNew() {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
async createFromProspect() {
|
||||
this.creating = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const site = await apiClient.post('/admin/hosting/sites/from-prospect/' + this.prospectId);
|
||||
window.location.href = '/admin/hosting/sites/' + site.id;
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message || 'Failed to create from prospect';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,11 +40,6 @@
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
|
||||
<a href="/admin/prospecting/prospects"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
|
||||
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
|
||||
Create from Prospect
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +90,11 @@
|
||||
title="View details">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<button type="button" @click="deleteSite(s)"
|
||||
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Delete">
|
||||
<span x-html="$icon('trash', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -144,6 +144,16 @@ function hostingSitesList() {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async deleteSite(site) {
|
||||
if (!confirm('Delete "' + site.business_name + '"? This will also delete the associated store.')) return;
|
||||
try {
|
||||
await apiClient.delete('/admin/hosting/sites/' + site.id);
|
||||
Utils.showToast('Site deleted', 'success');
|
||||
await this.loadSites();
|
||||
} catch (e) {
|
||||
Utils.showToast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
get startIndex() {
|
||||
if (this.pagination.total === 0) return 0;
|
||||
return (this.pagination.page - 1) * this.pagination.per_page + 1;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ site.business_name }} - Preview by HostWizard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
.hw-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background: linear-gradient(135deg, #0D9488, #14B8A6);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
|
||||
.hw-banner-logo { font-weight: 700; font-size: 16px; }
|
||||
.hw-banner-text { opacity: 0.9; }
|
||||
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
|
||||
.hw-banner-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid rgba(255,255,255,0.4);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
|
||||
.hw-iframe-container {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.hw-iframe-container iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hw-banner">
|
||||
<div class="hw-banner-left">
|
||||
<span class="hw-banner-logo">HostWizard</span>
|
||||
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
|
||||
</div>
|
||||
<div class="hw-banner-right">
|
||||
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hw-iframe-container">
|
||||
<iframe src="{{ store_url }}" title="Site preview"></iframe>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "auto-parts", "name": "Auto Parts & Garage", "description": "Template for auto parts shops, garages, and car dealers", "tags": ["automotive", "garage", "car", "parts"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "catalog",
|
||||
"title": "Catalog",
|
||||
"title_translations": {"en": "Parts Catalog", "fr": "Catalogue de pièces", "de": "Teilekatalog"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Parts Catalog</h2>\n<p>Browse our extensive catalog of auto parts for all major brands.</p>",
|
||||
"fr": "<h2>Catalogue de pièces</h2>\n<p>Parcourez notre catalogue complet de pièces auto pour toutes les grandes marques.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Contact Us</h2>\n<p>Visit our store or get in touch for parts inquiries.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Contactez-nous</h2>\n<p>Visitez notre magasin ou contactez-nous pour vos demandes de pièces.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "Your trusted auto parts specialist in {{city}}", "fr": "Votre spécialiste pièces auto de confiance à {{city}}"}},
|
||||
"background_type": "image",
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Browse Parts", "fr": "Voir les pièces"}}, "url": "/catalog", "style": "primary"},
|
||||
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Why Choose Us", "fr": "Pourquoi nous choisir"}},
|
||||
"items": [
|
||||
{"icon": "truck", "title": {"translations": {"en": "Fast Delivery", "fr": "Livraison rapide"}}, "description": {"translations": {"en": "Same-day delivery on in-stock parts", "fr": "Livraison le jour même pour les pièces en stock"}}},
|
||||
{"icon": "shield-check", "title": {"translations": {"en": "Quality Guaranteed", "fr": "Qualité garantie"}}, "description": {"translations": {"en": "OEM and certified aftermarket parts", "fr": "Pièces OEM et aftermarket certifiées"}}},
|
||||
{"icon": "currency-euro", "title": {"translations": {"en": "Best Prices", "fr": "Meilleurs prix"}}, "description": {"translations": {"en": "Competitive pricing on all brands", "fr": "Prix compétitifs sur toutes les marques"}}}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Need a specific part?", "fr": "Besoin d'une pièce spécifique ?"}},
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {"primary": "#dc2626", "secondary": "#991b1b", "accent": "#f59e0b", "background": "#fafafa", "text": "#18181b", "border": "#e4e4e7"},
|
||||
"font_family_heading": "Montserrat",
|
||||
"font_family_body": "Inter",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "construction", "name": "Construction & Renovation", "description": "Professional template for builders, renovators, and tradespeople", "tags": ["construction", "renovation", "building", "trades"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Get a Free Quote</h2>\n<p>Tell us about your project and we'll get back to you within 24 hours.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Demandez un devis gratuit</h2>\n<p>Décrivez-nous votre projet et nous vous recontacterons sous 24h.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "Quality construction and renovation in {{city}}", "fr": "Construction et rénovation de qualité à {{city}}"}},
|
||||
"background_type": "image",
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Get a Free Quote", "fr": "Devis gratuit"}}, "url": "/contact", "style": "primary"},
|
||||
{"text": {"translations": {"en": "Our Projects", "fr": "Nos réalisations"}}, "url": "/projects", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Our Services", "fr": "Nos Services"}},
|
||||
"items": [
|
||||
{"icon": "home", "title": {"translations": {"en": "New Construction", "fr": "Construction neuve"}}, "description": {"translations": {"en": "Custom-built homes and commercial buildings", "fr": "Maisons et bâtiments commerciaux sur mesure"}}},
|
||||
{"icon": "wrench", "title": {"translations": {"en": "Renovation", "fr": "Rénovation"}}, "description": {"translations": {"en": "Complete interior and exterior renovation", "fr": "Rénovation complète intérieure et extérieure"}}},
|
||||
{"icon": "color-swatch", "title": {"translations": {"en": "Painting & Finishing", "fr": "Peinture & Finitions"}}, "description": {"translations": {"en": "Professional painting and finishing work", "fr": "Travaux de peinture et finitions professionnels"}}},
|
||||
{"icon": "shield-check", "title": {"translations": {"en": "Insulation", "fr": "Isolation"}}, "description": {"translations": {"en": "Energy-efficient insulation solutions", "fr": "Solutions d'isolation éco-énergétiques"}}}
|
||||
]
|
||||
},
|
||||
"testimonials": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "What Our Clients Say", "fr": "Témoignages de nos clients"}},
|
||||
"items": []
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Ready to start your project?", "fr": "Prêt à démarrer votre projet ?"}},
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Request a Quote", "fr": "Demander un devis"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "projects",
|
||||
"title": "Projects",
|
||||
"title_translations": {"en": "Our Projects", "fr": "Nos Réalisations", "de": "Unsere Projekte"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Projects</h2>\n<p>Browse our portfolio of completed construction and renovation projects.</p>",
|
||||
"fr": "<h2>Nos Réalisations</h2>\n<p>Découvrez notre portfolio de projets de construction et rénovation réalisés.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "services",
|
||||
"title": "Services",
|
||||
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Services</h2>\n<p>We offer a comprehensive range of construction and renovation services.</p>\n<h3>Construction</h3>\n<p>From foundations to finishing touches, we handle every aspect of new builds.</p>\n<h3>Renovation</h3>\n<p>Transform your existing space with our expert renovation team.</p>\n<h3>Painting & Decoration</h3>\n<p>Professional interior and exterior painting services.</p>",
|
||||
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons une gamme complète de services de construction et rénovation.</p>\n<h3>Construction</h3>\n<p>Des fondations aux finitions, nous gérons chaque aspect des constructions neuves.</p>\n<h3>Rénovation</h3>\n<p>Transformez votre espace avec notre équipe de rénovation experte.</p>\n<h3>Peinture & Décoration</h3>\n<p>Services professionnels de peinture intérieure et extérieure.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {
|
||||
"primary": "#d97706",
|
||||
"secondary": "#92400e",
|
||||
"accent": "#fbbf24",
|
||||
"background": "#fafaf9",
|
||||
"text": "#1c1917",
|
||||
"border": "#d6d3d1"
|
||||
},
|
||||
"font_family_heading": "Montserrat",
|
||||
"font_family_body": "Open Sans",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
7
app/modules/hosting/templates_library/generic/meta.json
Normal file
7
app/modules/hosting/templates_library/generic/meta.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic Business",
|
||||
"description": "Clean, minimal template that works for any business type",
|
||||
"tags": ["general", "minimal", "any"],
|
||||
"languages": ["en", "fr", "de"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "about",
|
||||
"title": "About Us",
|
||||
"title_translations": {"en": "About Us", "fr": "À propos", "de": "Über uns"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content": "{{about_content}}",
|
||||
"content_translations": {
|
||||
"en": "<h2>About {{business_name}}</h2>\n<p>{{about_paragraph}}</p>",
|
||||
"fr": "<h2>À propos de {{business_name}}</h2>\n<p>{{about_paragraph}}</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contact", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Get in Touch</h2>\n<p>We'd love to hear from you. Reach out using the information below.</p>\n<ul>\n<li>Email: {{email}}</li>\n<li>Phone: {{phone}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Contactez-nous</h2>\n<p>N'hésitez pas à nous contacter.</p>\n<ul>\n<li>Email : {{email}}</li>\n<li>Téléphone : {{phone}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"show_in_header": false,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "{{meta_description}}", "fr": "{{meta_description}}"}},
|
||||
"background_type": "gradient",
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "What We Offer", "fr": "Nos Services"}},
|
||||
"items": [
|
||||
{"icon": "shield-check", "title": {"translations": {"en": "Quality", "fr": "Qualité"}}, "description": {"translations": {"en": "Committed to excellence in everything we do", "fr": "Engagés pour l'excellence dans tout ce que nous faisons"}}},
|
||||
{"icon": "clock", "title": {"translations": {"en": "Reliability", "fr": "Fiabilité"}}, "description": {"translations": {"en": "Dependable service you can count on", "fr": "Un service fiable sur lequel vous pouvez compter"}}},
|
||||
{"icon": "users", "title": {"translations": {"en": "Experience", "fr": "Expérience"}}, "description": {"translations": {"en": "Years of expertise at your service", "fr": "Des années d'expertise à votre service"}}}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Ready to get started?", "fr": "Prêt à commencer ?"}},
|
||||
"subtitle": {"translations": {"en": "Contact us today for a free consultation", "fr": "Contactez-nous pour une consultation gratuite"}},
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Get in Touch", "fr": "Nous Contacter"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/modules/hosting/templates_library/generic/theme.json
Normal file
15
app/modules/hosting/templates_library/generic/theme.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {
|
||||
"primary": "#3b82f6",
|
||||
"secondary": "#1e40af",
|
||||
"accent": "#f59e0b",
|
||||
"background": "#ffffff",
|
||||
"text": "#1e293b",
|
||||
"border": "#e2e8f0"
|
||||
},
|
||||
"font_family_heading": "Inter",
|
||||
"font_family_body": "Inter",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
40
app/modules/hosting/templates_library/manifest.json
Normal file
40
app/modules/hosting/templates_library/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"templates": [
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic Business",
|
||||
"description": "Clean, minimal template that works for any business type",
|
||||
"tags": ["general", "minimal", "any"],
|
||||
"pages": ["homepage", "about", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "restaurant",
|
||||
"name": "Restaurant & Dining",
|
||||
"description": "Elegant template for restaurants, cafés, bars, and catering",
|
||||
"tags": ["food", "dining", "hospitality", "café"],
|
||||
"pages": ["homepage", "about", "menu", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "construction",
|
||||
"name": "Construction & Renovation",
|
||||
"description": "Professional template for builders, renovators, and tradespeople",
|
||||
"tags": ["construction", "renovation", "building", "trades"],
|
||||
"pages": ["homepage", "services", "projects", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "auto-parts",
|
||||
"name": "Auto Parts & Garage",
|
||||
"description": "Template for auto parts shops, garages, and car dealers",
|
||||
"tags": ["automotive", "garage", "car", "parts"],
|
||||
"pages": ["homepage", "catalog", "contact"]
|
||||
},
|
||||
{
|
||||
"id": "professional-services",
|
||||
"name": "Professional Services",
|
||||
"description": "Template for lawyers, accountants, consultants, and agencies",
|
||||
"tags": ["professional", "consulting", "legal", "finance"],
|
||||
"pages": ["homepage", "services", "team", "contact"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "professional-services", "name": "Professional Services", "description": "Template for lawyers, accountants, consultants, and agencies", "tags": ["professional", "consulting", "legal", "finance"], "languages": ["en", "fr", "de"]}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"slug": "contact",
|
||||
"title": "Contact",
|
||||
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"show_in_footer": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Contact Us</h2>\n<p>Schedule a consultation or reach out with any questions.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
|
||||
"fr": "<h2>Contactez-nous</h2>\n<p>Planifiez une consultation ou posez-nous vos questions.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"slug": "homepage",
|
||||
"title": "{{business_name}}",
|
||||
"template": "full",
|
||||
"is_published": true,
|
||||
"sections": {
|
||||
"hero": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
|
||||
"subtitle": {"translations": {"en": "Professional expertise you can trust", "fr": "Une expertise professionnelle de confiance"}},
|
||||
"background_type": "gradient",
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Book a Consultation", "fr": "Prendre rendez-vous"}}, "url": "/contact", "style": "primary"},
|
||||
{"text": {"translations": {"en": "Our Expertise", "fr": "Notre expertise"}}, "url": "/services", "style": "secondary"}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Areas of Expertise", "fr": "Domaines d'expertise"}},
|
||||
"items": [
|
||||
{"icon": "briefcase", "title": {"translations": {"en": "Advisory", "fr": "Conseil"}}, "description": {"translations": {"en": "Strategic guidance tailored to your needs", "fr": "Conseils stratégiques adaptés à vos besoins"}}},
|
||||
{"icon": "document-text", "title": {"translations": {"en": "Compliance", "fr": "Conformité"}}, "description": {"translations": {"en": "Ensure regulatory compliance across your operations", "fr": "Assurez la conformité réglementaire de vos opérations"}}},
|
||||
{"icon": "chart-bar", "title": {"translations": {"en": "Analysis", "fr": "Analyse"}}, "description": {"translations": {"en": "Data-driven insights for informed decisions", "fr": "Analyses basées sur les données pour des décisions éclairées"}}}
|
||||
]
|
||||
},
|
||||
"cta": {
|
||||
"enabled": true,
|
||||
"title": {"translations": {"en": "Need professional guidance?", "fr": "Besoin d'un accompagnement professionnel ?"}},
|
||||
"buttons": [
|
||||
{"text": {"translations": {"en": "Schedule a Meeting", "fr": "Planifier un rendez-vous"}}, "url": "/contact", "style": "primary"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "services",
|
||||
"title": "Services",
|
||||
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Services</h2>\n<p>We provide comprehensive professional services to help your business thrive.</p>",
|
||||
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons des services professionnels complets pour aider votre entreprise à prospérer.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"slug": "team",
|
||||
"title": "Team",
|
||||
"title_translations": {"en": "Our Team", "fr": "Notre Équipe", "de": "Unser Team"},
|
||||
"template": "default",
|
||||
"is_published": true,
|
||||
"show_in_header": true,
|
||||
"content_translations": {
|
||||
"en": "<h2>Our Team</h2>\n<p>Meet the professionals behind {{business_name}}.</p>",
|
||||
"fr": "<h2>Notre Équipe</h2>\n<p>Découvrez les professionnels derrière {{business_name}}.</p>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"theme_name": "modern",
|
||||
"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "accent": "#3b82f6", "background": "#f8fafc", "text": "#0f172a", "border": "#cbd5e1"},
|
||||
"font_family_heading": "Merriweather",
|
||||
"font_family_body": "Source Sans Pro",
|
||||
"layout_style": "grid",
|
||||
"header_style": "fixed"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": "restaurant", "name": "Restaurant & Dining", "description": "Elegant template for restaurants, cafés, bars, and catering", "tags": ["food", "dining", "hospitality"], "languages": ["en", "fr", "de"]}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user