Compare commits

...

2 Commits

Author SHA1 Message Date
adbecd360b feat(cms): CMS-driven homepages, products section, placeholder resolution
Some checks failed
CI / ruff (push) Successful in 11s
CI / pytest (push) Failing after 51m41s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
- Add ProductCard/ProductsSection schema and _products.html section macro
- Rewrite seed script with 3-platform homepage sections (wizard, OMS, loyalty),
  platform marketing pages, and store defaults with {{store_name}} placeholders
- Add resolve_placeholders() to ContentPageService for store default pages
- Fix SQLAlchemy filter bugs: replace Python `is None` with `.is_(None)` across
  all ContentPageService query methods (was silently breaking all platform page lookups)
- Remove hardcoded orion fallback and delete homepage-orion.html
- Add placeholder hint box with click-to-copy in admin content page editor
- Export ProductCard/ProductsSection from cms schemas __init__

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:12:20 +01:00
ef9ea29643 feat: module-driven onboarding system + simplified 3-step signup
Add OnboardingProviderProtocol so modules declare their own post-signup
onboarding steps. The core OnboardingAggregator discovers enabled
providers and exposes a dashboard API (GET /dashboard/onboarding).
A session-scoped banner on the store dashboard shows a checklist that
guides merchants through setup without blocking signup.

Signup is simplified from 4 steps to 3 (Plan → Account → Payment):
store creation is merged into account creation, store language is
captured from the user's browsing language, and platform-specific
template branching is removed.

Includes 47 unit and integration tests covering all new providers,
the aggregator, the API endpoint, and the signup service changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:39:42 +01:00
37 changed files with 3187 additions and 1615 deletions

View File

@@ -40,6 +40,7 @@ class SignupStartRequest(BaseModel):
tier_code: str tier_code: str
is_annual: bool = False is_annual: bool = False
platform_code: str platform_code: str
language: str = "fr"
class SignupStartResponse(BaseModel): class SignupStartResponse(BaseModel):
@@ -64,12 +65,14 @@ class CreateAccountRequest(BaseModel):
class CreateAccountResponse(BaseModel): class CreateAccountResponse(BaseModel):
"""Response from account creation.""" """Response from account creation (includes auto-created store)."""
session_id: str session_id: str
user_id: int user_id: int
merchant_id: int merchant_id: int
stripe_customer_id: str stripe_customer_id: str
store_id: int
store_code: str
class CreateStoreRequest(BaseModel): class CreateStoreRequest(BaseModel):
@@ -137,6 +140,7 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
tier_code=request.tier_code, tier_code=request.tier_code,
is_annual=request.is_annual, is_annual=request.is_annual,
platform_code=request.platform_code, platform_code=request.platform_code,
language=request.language,
) )
return SignupStartResponse( return SignupStartResponse(
@@ -175,6 +179,8 @@ async def create_account(
user_id=result.user_id, user_id=result.user_id,
merchant_id=result.merchant_id, merchant_id=result.merchant_id,
stripe_customer_id=result.stripe_customer_id, stripe_customer_id=result.stripe_customer_id,
store_id=result.store_id,
store_code=result.store_code,
) )

View File

@@ -49,6 +49,7 @@ if TYPE_CHECKING:
from app.modules.contracts.cms import MediaUsageProviderProtocol from app.modules.contracts.cms import MediaUsageProviderProtocol
from app.modules.contracts.features import FeatureProviderProtocol from app.modules.contracts.features import FeatureProviderProtocol
from app.modules.contracts.metrics import MetricsProviderProtocol from app.modules.contracts.metrics import MetricsProviderProtocol
from app.modules.contracts.onboarding import OnboardingProviderProtocol
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
@@ -486,6 +487,29 @@ class ModuleDefinition:
# to report where media is being used. # to report where media is being used.
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
# =========================================================================
# Onboarding Provider (Module-Driven Post-Signup Onboarding)
# =========================================================================
# Callable that returns an OnboardingProviderProtocol implementation.
# Modules declare onboarding steps (what needs to be configured after signup)
# and provide completion checks. The core module's OnboardingAggregator
# discovers and aggregates all providers into a dashboard checklist banner.
#
# Example:
# def _get_onboarding_provider():
# from app.modules.marketplace.services.marketplace_onboarding import (
# marketplace_onboarding_provider,
# )
# return marketplace_onboarding_provider
#
# marketplace_module = ModuleDefinition(
# code="marketplace",
# onboarding_provider=_get_onboarding_provider,
# )
#
# The provider will be discovered by core's OnboardingAggregator service.
onboarding_provider: "Callable[[], OnboardingProviderProtocol] | None" = None
# ========================================================================= # =========================================================================
# Menu Item Methods (Legacy - uses menu_items dict of IDs) # Menu Item Methods (Legacy - uses menu_items dict of IDs)
# ========================================================================= # =========================================================================
@@ -955,6 +979,24 @@ class ModuleDefinition:
return None return None
return self.media_usage_provider() return self.media_usage_provider()
# =========================================================================
# Onboarding Provider Methods
# =========================================================================
def has_onboarding_provider(self) -> bool:
"""Check if this module has an onboarding provider."""
return self.onboarding_provider is not None
def get_onboarding_provider_instance(self) -> "OnboardingProviderProtocol | None":
"""Get the onboarding provider instance for this module.
Returns:
OnboardingProviderProtocol instance, or None
"""
if self.onboarding_provider is None:
return None
return self.onboarding_provider()
# ========================================================================= # =========================================================================
# Magic Methods # Magic Methods
# ========================================================================= # =========================================================================

View File

@@ -114,14 +114,8 @@ async def signup_page(
context["is_annual"] = annual context["is_annual"] = annual
context["tiers"] = _get_tiers_data(db, platform_id=platform.id) context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
# Route to platform-specific signup template
if platform.code == "loyalty":
template_name = "billing/platform/signup-loyalty.html"
else:
template_name = "billing/platform/signup.html"
return templates.TemplateResponse( return templates.TemplateResponse(
template_name, "billing/platform/signup.html",
context, context,
) )

View File

@@ -84,11 +84,13 @@ class SignupSessionData:
@dataclass @dataclass
class AccountCreationResult: class AccountCreationResult:
"""Result of account creation.""" """Result of account creation (includes auto-created store)."""
user_id: int user_id: int
merchant_id: int merchant_id: int
stripe_customer_id: str stripe_customer_id: str
store_id: int
store_code: str
@dataclass @dataclass
@@ -131,6 +133,7 @@ class SignupService:
tier_code: str, tier_code: str,
is_annual: bool, is_annual: bool,
platform_code: str, platform_code: str,
language: str = "fr",
) -> str: ) -> str:
""" """
Create a new signup session. Create a new signup session.
@@ -139,6 +142,7 @@ class SignupService:
tier_code: The subscription tier code tier_code: The subscription tier code
is_annual: Whether annual billing is selected is_annual: Whether annual billing is selected
platform_code: Platform code (e.g., 'loyalty', 'oms') platform_code: Platform code (e.g., 'loyalty', 'oms')
language: User's browsing language (from lang cookie)
Returns: Returns:
The session ID The session ID
@@ -171,6 +175,7 @@ class SignupService:
"tier_code": tier.value, "tier_code": tier.value,
"is_annual": is_annual, "is_annual": is_annual,
"platform_code": platform_code, "platform_code": platform_code,
"language": language,
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
} }
@@ -304,10 +309,10 @@ class SignupService:
phone: str | None = None, phone: str | None = None,
) -> AccountCreationResult: ) -> AccountCreationResult:
""" """
Create user and merchant accounts. Create user, merchant, store, and subscription in a single atomic step.
Creates User + Merchant + Stripe Customer. Store creation is a Creates User + Merchant + Store + Stripe Customer + MerchantSubscription.
separate step (create_store) so each platform can customize it. Store name defaults to merchant_name, language from signup session.
Args: Args:
db: Database session db: Database session
@@ -320,7 +325,7 @@ class SignupService:
phone: Optional phone number phone: Optional phone number
Returns: Returns:
AccountCreationResult with user and merchant IDs AccountCreationResult with user, merchant, and store IDs
Raises: Raises:
ResourceNotFoundException: If session not found ResourceNotFoundException: If session not found
@@ -338,7 +343,7 @@ class SignupService:
username = self.generate_unique_username(db, email) username = self.generate_unique_username(db, email)
# Create User # Create User
from app.modules.tenancy.models import Merchant, User from app.modules.tenancy.models import Merchant, Store, User
user = User( user = User(
email=email, email=email,
@@ -362,8 +367,7 @@ class SignupService:
db.add(merchant) db.add(merchant)
db.flush() db.flush()
# Create Stripe Customer (linked to merchant, not store) # Create Stripe Customer
# We use a temporary store-like object for Stripe metadata
stripe_customer_id = stripe_service.create_customer_for_merchant( stripe_customer_id = stripe_service.create_customer_for_merchant(
merchant=merchant, merchant=merchant,
email=email, email=email,
@@ -375,7 +379,38 @@ class SignupService:
}, },
) )
db.commit() # SVC-006 - Atomic account creation needs commit # Create Store (name = merchant_name, language from browsing session)
store_code = self.generate_unique_store_code(db, merchant_name)
subdomain = self.generate_unique_subdomain(db, merchant_name)
language = session.get("language", "fr")
store = Store(
merchant_id=merchant.id,
store_code=store_code,
subdomain=subdomain,
name=merchant_name,
contact_email=email,
is_active=True,
)
if language:
store.default_language = language
db.add(store)
db.flush()
# Resolve platform and create subscription
platform_id = self._resolve_platform_id(db, session)
subscription = sub_service.create_merchant_subscription(
db=db,
merchant_id=merchant.id,
platform_id=platform_id,
tier_code=session.get("tier_code", "essential"),
trial_days=settings.stripe_trial_days,
is_annual=session.get("is_annual", False),
)
subscription.stripe_customer_id = stripe_customer_id
db.commit() # SVC-006 - Atomic account + store creation
# Update session # Update session
self.update_session(session_id, { self.update_session(session_id, {
@@ -384,18 +419,24 @@ class SignupService:
"merchant_name": merchant_name, "merchant_name": merchant_name,
"email": email, "email": email,
"stripe_customer_id": stripe_customer_id, "stripe_customer_id": stripe_customer_id,
"store_id": store.id,
"store_code": store_code,
"platform_id": platform_id,
"step": "account_created", "step": "account_created",
}) })
logger.info( logger.info(
f"Created account for {email}: " f"Created account + store for {email}: "
f"user_id={user.id}, merchant_id={merchant.id}" f"user_id={user.id}, merchant_id={merchant.id}, "
f"store_code={store_code}"
) )
return AccountCreationResult( return AccountCreationResult(
user_id=user.id, user_id=user.id,
merchant_id=merchant.id, merchant_id=merchant.id,
stripe_customer_id=stripe_customer_id, stripe_customer_id=stripe_customer_id,
store_id=store.id,
store_code=store_code,
) )
# ========================================================================= # =========================================================================
@@ -512,7 +553,7 @@ class SignupService:
Raises: Raises:
ResourceNotFoundException: If session not found ResourceNotFoundException: If session not found
ValidationException: If store not created yet ValidationException: If account not created yet
""" """
session = self.get_session_or_raise(session_id) session = self.get_session_or_raise(session_id)
@@ -523,12 +564,6 @@ class SignupService:
field="session_id", field="session_id",
) )
if not session.get("store_id"):
raise ValidationException(
message="Store not created. Please complete the store step first.",
field="session_id",
)
# Create SetupIntent # Create SetupIntent
setup_intent = stripe_service.create_setup_intent( setup_intent = stripe_service.create_setup_intent(
customer_id=stripe_customer_id, customer_id=stripe_customer_id,
@@ -775,21 +810,11 @@ class SignupService:
self, db: Session, session: dict, store_code: str self, db: Session, session: dict, store_code: str
) -> str: ) -> str:
""" """
Determine redirect URL after signup based on platform. Determine redirect URL after signup.
Marketplace platforms → onboarding wizard. Always redirects to the store dashboard. Platform-specific onboarding
Other platforms (loyalty, etc.) → dashboard. is handled by the dashboard's onboarding banner (module-driven).
""" """
from app.modules.service import module_service
platform_id = session.get("platform_id")
if platform_id:
try:
if module_service.is_module_enabled(db, platform_id, "marketplace"):
return f"/store/{store_code}/onboarding"
except Exception:
pass # If check fails, default to dashboard
return f"/store/{store_code}/dashboard" return f"/store/{store_code}/dashboard"

View File

@@ -1,520 +0,0 @@
{# app/templates/platform/signup-loyalty.html #}
{# Loyalty Platform Signup Wizard — 4 steps: Plan → Account → Store → Payment #}
{% extends "platform/base.html" %}
{% block title %}Start Your Free Trial - RewardFlow{% endblock %}
{% block extra_head %}
{# Stripe.js for payment #}
<script defer src="https://js.stripe.com/v3/"></script>
{% endblock %}
{% block content %}
<div x-data="loyaltySignupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
{# Progress Steps #}
<div class="mb-12">
<div class="flex items-center justify-between">
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Set Up Store', 'Payment']" :key="index">
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
<template x-if="currentStep > index + 1">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</template>
<template x-if="currentStep <= index + 1">
<span x-text="index + 1"></span>
</template>
</div>
<span class="ml-2 text-sm font-medium hidden sm:inline"
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
x-text="stepName"></span>
<template x-if="index < 3">
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-full bg-indigo-600 rounded transition-all"
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
</div>
</template>
</div>
</template>
</div>
</div>
{# Form Card #}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{# ===============================================================
STEP 1: SELECT PLAN
=============================================================== #}
<div x-show="currentStep === 1" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
{# Billing Toggle #}
<div class="flex items-center justify-center mb-8 space-x-4">
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
<button @click="isAnnual = !isAnnual"
class="relative w-12 h-6 rounded-full transition-colors"
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
:class="isAnnual ? 'translate-x-6' : ''"></span>
</button>
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
Annual <span class="text-green-600 text-xs">Save 17%</span>
</span>
</div>
{# Tier Options #}
<div class="space-y-4">
{% for tier in tiers %}
{% if not tier.is_enterprise %}
<label class="block">
<input type="radio" name="tier" value="{{ tier.code }}"
x-model="selectedTier" class="hidden peer"/>
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
border-gray-200 dark:border-gray-700 hover:border-gray-300">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
<p class="text-sm text-gray-500">
{% if tier.products_limit %}{{ tier.products_limit }} loyalty programs{% else %}Unlimited{% endif %}
&bull;
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
</p>
</div>
<div class="text-right">
<template x-if="!isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
</template>
<template x-if="isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
</template>
</div>
</div>
</div>
</label>
{% endif %}
{% endfor %}
</div>
{# Free Trial Note #}
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-sm text-green-800 dark:text-green-300">
<strong>{{ trial_days }}-day free trial.</strong>
We'll collect your payment info, but you won't be charged until the trial ends.
</p>
</div>
<button @click="startSignup()"
:disabled="!selectedTier || loading"
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
Continue
</button>
</div>
{# ===============================================================
STEP 2: CREATE ACCOUNT
=============================================================== #}
<div x-show="currentStep === 2" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span class="text-red-500">*</span> Required fields
</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.firstName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.lastName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.merchantName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="account.email" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-red-500">*</span>
</label>
<input type="password" x-model="account.password" required minlength="8"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<template x-if="accountError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createAccount()"
:disabled="loading || !isAccountValid()"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue
</button>
</div>
</div>
{# ===============================================================
STEP 3: SET UP STORE
=============================================================== #}
<div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Set Up Your Store</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">Your store is where your loyalty programs live. You can change these settings later.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Store Name
</label>
<input type="text" x-model="storeName"
:placeholder="account.merchantName || 'My Store'"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Defaults to your business name if left empty</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Store Language
</label>
<select x-model="storeLanguage"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="lb">Lëtzebuergesch</option>
</select>
</div>
<template x-if="storeError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="storeError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 2"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createStore()"
:disabled="loading"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue to Payment
</button>
</div>
</div>
{# ===============================================================
STEP 4: PAYMENT
=============================================================== #}
<div x-show="currentStep === 4" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
{# Stripe Card Element #}
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 3"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="submitPayment()"
:disabled="loading || paymentProcessing"
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
<template x-if="paymentProcessing">
<span>Processing...</span>
</template>
<template x-if="!paymentProcessing">
<span>Start Free Trial</span>
</template>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function loyaltySignupWizard() {
return {
currentStep: 1,
loading: false,
sessionId: null,
platformCode: '{{ platform.code if platform else "loyalty" }}',
// Step 1: Plan
selectedTier: '{{ selected_tier or "professional" }}',
isAnnual: {{ 'true' if is_annual else 'false' }},
// Step 2: Account
account: {
firstName: '',
lastName: '',
merchantName: '',
email: '',
password: ''
},
accountError: null,
// Step 3: Store
storeName: '',
storeLanguage: 'fr',
storeError: null,
// Step 4: Payment
stripe: null,
cardElement: null,
paymentProcessing: false,
clientSecret: null,
init() {
// Check URL params for pre-selection
const params = new URLSearchParams(window.location.search);
if (params.get('tier')) {
this.selectedTier = params.get('tier');
}
if (params.get('annual') === 'true') {
this.isAnnual = true;
}
// Initialize Stripe when we get to step 4
this.$watch('currentStep', (step) => {
if (step === 4) {
this.initStripe();
}
});
},
async startSignup() {
this.loading = true;
try {
const response = await fetch('/api/v1/platform/signup/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier_code: this.selectedTier,
is_annual: this.isAnnual,
platform_code: this.platformCode
})
});
const data = await response.json();
if (response.ok) {
this.sessionId = data.session_id;
this.currentStep = 2;
} else {
alert(data.detail || 'Failed to start signup');
}
} catch (error) {
console.error('Error:', error);
alert('Failed to start signup. Please try again.');
} finally {
this.loading = false;
}
},
isAccountValid() {
return this.account.firstName.trim() &&
this.account.lastName.trim() &&
this.account.merchantName.trim() &&
this.account.email.trim() &&
this.account.password.length >= 8;
},
async createAccount() {
this.loading = true;
this.accountError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
email: this.account.email,
password: this.account.password,
first_name: this.account.firstName,
last_name: this.account.lastName,
merchant_name: this.account.merchantName
})
});
const data = await response.json();
if (response.ok) {
// Default store name to merchant name
if (!this.storeName) {
this.storeName = this.account.merchantName;
}
this.currentStep = 3;
} else {
this.accountError = data.detail || 'Failed to create account';
}
} catch (error) {
console.error('Error:', error);
this.accountError = 'Failed to create account. Please try again.';
} finally {
this.loading = false;
}
},
async createStore() {
this.loading = true;
this.storeError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
store_name: this.storeName || null,
language: this.storeLanguage
})
});
const data = await response.json();
if (response.ok) {
this.currentStep = 4;
} else {
this.storeError = data.detail || 'Failed to create store';
}
} catch (error) {
console.error('Error:', error);
this.storeError = 'Failed to create store. Please try again.';
} finally {
this.loading = false;
}
},
async initStripe() {
{% if stripe_publishable_key %}
this.stripe = Stripe('{{ stripe_publishable_key }}');
const elements = this.stripe.elements();
this.cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#374151',
'::placeholder': { color: '#9CA3AF' }
}
}
});
this.cardElement.mount('#card-element');
this.cardElement.on('change', (event) => {
const displayError = document.getElementById('card-errors');
displayError.textContent = event.error ? event.error.message : '';
});
// Get SetupIntent
try {
const response = await fetch('/api/v1/platform/signup/setup-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId })
});
const data = await response.json();
if (response.ok) {
this.clientSecret = data.client_secret;
}
} catch (error) {
console.error('Error getting SetupIntent:', error);
}
{% else %}
console.warn('Stripe not configured');
{% endif %}
},
async submitPayment() {
if (!this.stripe || !this.clientSecret) {
alert('Payment not configured. Please contact support.');
return;
}
this.paymentProcessing = true;
try {
const { setupIntent, error } = await this.stripe.confirmCardSetup(
this.clientSecret,
{ payment_method: { card: this.cardElement } }
);
if (error) {
document.getElementById('card-errors').textContent = error.message;
this.paymentProcessing = false;
return;
}
// Complete signup
const response = await fetch('/api/v1/platform/signup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
setup_intent_id: setupIntent.id
})
});
const data = await response.json();
if (response.ok) {
// Store access token for automatic login
if (data.access_token) {
localStorage.setItem('store_token', data.access_token);
localStorage.setItem('storeCode', data.store_code);
}
window.location.href = '/signup/success?store_code=' + data.store_code;
} else {
alert(data.detail || 'Failed to complete signup');
}
} catch (error) {
console.error('Payment error:', error);
alert('Payment failed. Please try again.');
} finally {
this.paymentProcessing = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -1,5 +1,5 @@
{# app/templates/platform/signup.html #} {# app/templates/platform/signup.html #}
{# Multi-step Signup Wizard #} {# 3-Step Signup Wizard: Plan → Account → Payment #}
{% extends "platform/base.html" %} {% extends "platform/base.html" %}
{% block title %}Start Your Free Trial - Orion{% endblock %} {% block title %}Start Your Free Trial - Orion{% endblock %}
@@ -16,8 +16,8 @@
{# Progress Steps #} {# Progress Steps #}
<div class="mb-12"> <div class="mb-12">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index"> <template x-for="(stepName, index) in ['Select Plan', 'Account', 'Payment']" :key="index">
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''"> <div class="flex items-center" :class="index < 2 ? 'flex-1' : ''">
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors" <div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'"> :class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
<template x-if="currentStep > index + 1"> <template x-if="currentStep > index + 1">
@@ -32,7 +32,7 @@
<span class="ml-2 text-sm font-medium hidden sm:inline" <span class="ml-2 text-sm font-medium hidden sm:inline"
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'" :class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
x-text="stepName"></span> x-text="stepName"></span>
<template x-if="index < 3"> <template x-if="index < 2">
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded"> <div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-full bg-indigo-600 rounded transition-all" <div class="h-full bg-indigo-600 rounded transition-all"
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div> :style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
@@ -87,10 +87,10 @@
</div> </div>
<div class="text-right"> <div class="text-right">
<template x-if="!isAnnual"> <template x-if="!isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}/mo</span> <span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}&euro;/mo</span>
</template> </template>
<template x-if="isAnnual"> <template x-if="isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}/mo</span> <span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}&euro;/mo</span>
</template> </template>
</div> </div>
</div> </div>
@@ -116,52 +116,9 @@
</div> </div>
{# =============================================================== {# ===============================================================
STEP 2: CLAIM LETZSHOP SHOP (Optional) STEP 2: CREATE ACCOUNT
=============================================================== #} =============================================================== #}
<div x-show="currentStep === 2" class="p-8"> <div x-show="currentStep === 2" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
<div class="space-y-4">
<input
type="text"
x-model="letzshopUrl"
placeholder="letzshop.lu/vendors/your-shop"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
/>
<template x-if="letzshopStore">
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-green-800 dark:text-green-300">
Found: <strong x-text="letzshopStore.name"></strong>
</p>
</div>
</template>
<template x-if="letzshopError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="claimStore()"
:disabled="loading"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
</button>
</div>
</div>
{# ===============================================================
STEP 3: CREATE ACCOUNT
=============================================================== #}
<div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6"> <p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span class="text-red-500">*</span> Required fields <span class="text-red-500">*</span> Required fields
@@ -187,7 +144,7 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Merchant Name <span class="text-red-500">*</span> Business Name <span class="text-red-500">*</span>
</label> </label>
<input type="text" x-model="account.merchantName" required <input type="text" x-model="account.merchantName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/> class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
@@ -218,7 +175,7 @@
</div> </div>
<div class="mt-8 flex gap-4"> <div class="mt-8 flex gap-4">
<button @click="currentStep = 2" <button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl"> class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back Back
</button> </button>
@@ -231,9 +188,9 @@
</div> </div>
{# =============================================================== {# ===============================================================
STEP 4: PAYMENT STEP 3: PAYMENT
=============================================================== #} =============================================================== #}
<div x-show="currentStep === 4" class="p-8"> <div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p> <p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
@@ -242,7 +199,7 @@
<div id="card-errors" class="text-red-600 text-sm mt-2"></div> <div id="card-errors" class="text-red-600 text-sm mt-2"></div>
<div class="mt-8 flex gap-4"> <div class="mt-8 flex gap-4">
<button @click="currentStep = 3" <button @click="currentStep = 2"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl"> class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back Back
</button> </button>
@@ -276,12 +233,7 @@ function signupWizard() {
selectedTier: '{{ selected_tier or "professional" }}', selectedTier: '{{ selected_tier or "professional" }}',
isAnnual: {{ 'true' if is_annual else 'false' }}, isAnnual: {{ 'true' if is_annual else 'false' }},
// Step 2: Letzshop // Step 2: Account
letzshopUrl: '',
letzshopStore: null,
letzshopError: null,
// Step 3: Account
account: { account: {
firstName: '', firstName: '',
lastName: '', lastName: '',
@@ -291,7 +243,7 @@ function signupWizard() {
}, },
accountError: null, accountError: null,
// Step 4: Payment // Step 3: Payment
stripe: null, stripe: null,
cardElement: null, cardElement: null,
paymentProcessing: false, paymentProcessing: false,
@@ -306,13 +258,10 @@ function signupWizard() {
if (params.get('annual') === 'true') { if (params.get('annual') === 'true') {
this.isAnnual = true; this.isAnnual = true;
} }
if (params.get('letzshop')) {
this.letzshopUrl = params.get('letzshop');
}
// Initialize Stripe when we get to step 4 // Initialize Stripe when we get to step 3
this.$watch('currentStep', (step) => { this.$watch('currentStep', (step) => {
if (step === 4) { if (step === 3) {
this.initStripe(); this.initStripe();
} }
}); });
@@ -327,7 +276,8 @@ function signupWizard() {
body: JSON.stringify({ body: JSON.stringify({
tier_code: this.selectedTier, tier_code: this.selectedTier,
is_annual: this.isAnnual, is_annual: this.isAnnual,
platform_code: '{{ platform.code }}' platform_code: '{{ platform.code }}',
language: '{{ current_language|default("fr") }}'
}) })
}); });
@@ -346,59 +296,6 @@ function signupWizard() {
} }
}, },
async claimStore() {
if (this.letzshopUrl.trim()) {
this.loading = true;
this.letzshopError = null;
try {
// First lookup the store
const lookupResponse = await fetch('/api/v1/platform/letzshop-stores/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.letzshopUrl })
});
const lookupData = await lookupResponse.json();
if (lookupData.found && !lookupData.store.is_claimed) {
this.letzshopStore = lookupData.store;
// Claim the store
const claimResponse = await fetch('/api/v1/platform/signup/claim-store', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
letzshop_slug: lookupData.store.slug
})
});
if (claimResponse.ok) {
const claimData = await claimResponse.json();
this.account.merchantName = claimData.store_name || '';
this.currentStep = 3;
} else {
const error = await claimResponse.json();
this.letzshopError = error.detail || 'Failed to claim store';
}
} else if (lookupData.store?.is_claimed) {
this.letzshopError = 'This shop has already been claimed.';
} else {
this.letzshopError = lookupData.error || 'Shop not found.';
}
} catch (error) {
console.error('Error:', error);
this.letzshopError = 'Failed to lookup store.';
} finally {
this.loading = false;
}
} else {
// Skip this step
this.currentStep = 3;
}
},
isAccountValid() { isAccountValid() {
return this.account.firstName.trim() && return this.account.firstName.trim() &&
this.account.lastName.trim() && this.account.lastName.trim() &&
@@ -427,7 +324,7 @@ function signupWizard() {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
this.currentStep = 4; this.currentStep = 3;
} else { } else {
this.accountError = data.detail || 'Failed to create account'; this.accountError = data.detail || 'Failed to create account';
} }
@@ -516,7 +413,6 @@ function signupWizard() {
if (data.access_token) { if (data.access_token) {
localStorage.setItem('store_token', data.access_token); localStorage.setItem('store_token', data.access_token);
localStorage.setItem('storeCode', data.store_code); localStorage.setItem('storeCode', data.store_code);
console.log('Store token stored for automatic login');
} }
window.location.href = '/signup/success?store_code=' + data.store_code; window.location.href = '/signup/success?store_code=' + data.store_code;
} else { } else {

View File

@@ -0,0 +1,364 @@
# app/modules/billing/tests/unit/test_signup_service.py
"""Unit tests for SignupService (simplified 3-step signup)."""
import uuid
from unittest.mock import patch
import pytest
from app.exceptions import (
ConflictException,
ResourceNotFoundException,
ValidationException,
)
from app.modules.billing.models import TierCode
from app.modules.billing.services.signup_service import SignupService, _signup_sessions
@pytest.fixture(autouse=True)
def _clear_sessions():
"""Clear in-memory signup sessions before each test."""
_signup_sessions.clear()
yield
_signup_sessions.clear()
@pytest.mark.unit
@pytest.mark.billing
class TestSignupServiceSession:
"""Tests for SignupService session management."""
def setup_method(self):
self.service = SignupService()
def test_create_session_stores_language(self):
"""create_session stores the user's browsing language."""
session_id = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code="loyalty",
language="de",
)
session = self.service.get_session(session_id)
assert session is not None
assert session["language"] == "de"
def test_create_session_default_language_fr(self):
"""create_session defaults to French when no language provided."""
session_id = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code="oms",
)
session = self.service.get_session(session_id)
assert session["language"] == "fr"
def test_create_session_stores_platform_code(self):
"""create_session stores the platform code."""
session_id = self.service.create_session(
tier_code=TierCode.PROFESSIONAL.value,
is_annual=True,
platform_code="loyalty",
language="en",
)
session = self.service.get_session(session_id)
assert session["platform_code"] == "loyalty"
assert session["is_annual"] is True
assert session["tier_code"] == TierCode.PROFESSIONAL.value
def test_create_session_raises_without_platform_code(self):
"""create_session raises ValidationException when platform_code is empty."""
with pytest.raises(ValidationException):
self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code="",
)
def test_get_session_or_raise_missing(self):
"""get_session_or_raise raises ResourceNotFoundException for invalid session."""
with pytest.raises(ResourceNotFoundException):
self.service.get_session_or_raise("nonexistent_session_id")
def test_delete_session(self):
"""delete_session removes the session from storage."""
session_id = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code="oms",
)
assert self.service.get_session(session_id) is not None
self.service.delete_session(session_id)
assert self.service.get_session(session_id) is None
@pytest.mark.unit
@pytest.mark.billing
class TestSignupServiceAccountCreation:
"""Tests for SignupService.create_account (merged store creation)."""
def setup_method(self):
self.service = SignupService()
def test_create_account_creates_store(self, db):
"""create_account creates User + Merchant + Store atomically."""
from app.modules.billing.models import SubscriptionTier
from app.modules.tenancy.models import Platform
# Create platform
platform = Platform(
code=f"test_{uuid.uuid4().hex[:8]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
# Create a tier for the platform
tier = SubscriptionTier(
code=TierCode.ESSENTIAL.value,
name="Essential",
platform_id=platform.id,
price_monthly_cents=0,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
# Create session
session_id = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code=platform.code,
language="de",
)
# Mock Stripe
with patch(
"app.modules.billing.services.signup_service.stripe_service"
) as mock_stripe:
mock_stripe.create_customer_for_merchant.return_value = "cus_test123"
result = self.service.create_account(
db=db,
session_id=session_id,
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
password="securepass123", # noqa: SEC-001 # noqa: SEC-001
first_name="John",
last_name="Doe",
merchant_name="John's Shop",
)
# Verify result includes store info
assert result.user_id is not None
assert result.merchant_id is not None
assert result.store_id is not None
assert result.store_code is not None
assert result.stripe_customer_id == "cus_test123"
# Verify store was created with correct language
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == result.store_id).first()
assert store is not None
assert store.name == "John's Shop"
assert store.default_language == "de"
def test_create_account_uses_session_language(self, db):
"""create_account sets store default_language from the signup session."""
from app.modules.billing.models import SubscriptionTier
from app.modules.tenancy.models import Platform
platform = Platform(
code=f"test_{uuid.uuid4().hex[:8]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
tier = SubscriptionTier(
code=TierCode.ESSENTIAL.value,
name="Essential",
platform_id=platform.id,
price_monthly_cents=0,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
session_id = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code=platform.code,
language="en",
)
with patch(
"app.modules.billing.services.signup_service.stripe_service"
) as mock_stripe:
mock_stripe.create_customer_for_merchant.return_value = "cus_test456"
result = self.service.create_account(
db=db,
session_id=session_id,
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
password="securepass123", # noqa: SEC-001
first_name="Jane",
last_name="Smith",
merchant_name="Jane's Bakery",
)
from app.modules.tenancy.models import Store
store = db.query(Store).filter(Store.id == result.store_id).first()
assert store.default_language == "en"
def test_create_account_rejects_duplicate_email(self, db):
"""create_account raises ConflictException for existing email."""
from app.modules.billing.models import SubscriptionTier
from app.modules.tenancy.models import Platform
platform = Platform(
code=f"test_{uuid.uuid4().hex[:8]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
tier = SubscriptionTier(
code=TierCode.ESSENTIAL.value,
name="Essential",
platform_id=platform.id,
price_monthly_cents=0,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
email = f"dup_{uuid.uuid4().hex[:8]}@example.com"
# Create first account
session_id1 = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code=platform.code,
)
with patch(
"app.modules.billing.services.signup_service.stripe_service"
) as mock_stripe:
mock_stripe.create_customer_for_merchant.return_value = "cus_first"
self.service.create_account(
db=db,
session_id=session_id1,
email=email,
password="securepass123", # noqa: SEC-001
first_name="First",
last_name="User",
merchant_name="First Shop",
)
# Try to create second account with same email
session_id2 = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code=platform.code,
)
with pytest.raises(ConflictException):
self.service.create_account(
db=db,
session_id=session_id2,
email=email,
password="securepass123", # noqa: SEC-001
first_name="Second",
last_name="User",
merchant_name="Second Shop",
)
def test_create_account_updates_session(self, db):
"""create_account updates session with user/merchant/store IDs."""
from app.modules.billing.models import SubscriptionTier
from app.modules.tenancy.models import Platform
platform = Platform(
code=f"test_{uuid.uuid4().hex[:8]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
tier = SubscriptionTier(
code=TierCode.ESSENTIAL.value,
name="Essential",
platform_id=platform.id,
price_monthly_cents=0,
display_order=1,
is_active=True,
is_public=True,
)
db.add(tier)
db.commit()
session_id = self.service.create_session(
tier_code=TierCode.ESSENTIAL.value,
is_annual=False,
platform_code=platform.code,
)
with patch(
"app.modules.billing.services.signup_service.stripe_service"
) as mock_stripe:
mock_stripe.create_customer_for_merchant.return_value = "cus_test789"
result = self.service.create_account(
db=db,
session_id=session_id,
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
password="securepass123", # noqa: SEC-001
first_name="Test",
last_name="User",
merchant_name="Test Shop",
)
session = self.service.get_session(session_id)
assert session["user_id"] == result.user_id
assert session["merchant_id"] == result.merchant_id
assert session["store_id"] == result.store_id
assert session["store_code"] == result.store_code
assert session["stripe_customer_id"] == "cus_test789"
assert session["step"] == "account_created"
@pytest.mark.unit
@pytest.mark.billing
class TestSignupServicePostRedirect:
"""Tests for SignupService._get_post_signup_redirect."""
def setup_method(self):
self.service = SignupService()
def test_always_redirects_to_dashboard(self, db):
"""Post-signup redirect always goes to store dashboard."""
session = {"platform_code": "loyalty"}
url = self.service._get_post_signup_redirect(db, session, "MY_STORE")
assert url == "/store/MY_STORE/dashboard"
def test_redirect_for_oms_platform(self, db):
"""OMS platform also redirects to dashboard (not onboarding wizard)."""
session = {"platform_code": "oms"}
url = self.service._get_post_signup_redirect(db, session, "OMS_STORE")
assert url == "/store/OMS_STORE/dashboard"

View File

@@ -164,46 +164,13 @@ async def homepage(
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}") logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
return templates.TemplateResponse(template_path, context) return templates.TemplateResponse(template_path, context)
# Fallback: Default orion homepage (no CMS content) # Fallback: Default homepage template with placeholder content
logger.info("[HOMEPAGE] No CMS homepage found, using default orion template") logger.info("[HOMEPAGE] No CMS homepage found, using default template with placeholders")
context = get_platform_context(request, db) context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data(db) context["tiers"] = _get_tiers_data(db)
# Add-ons (hardcoded for now, will come from DB)
context["addons"] = [
{
"code": "domain",
"name": "Custom Domain",
"description": "Use your own domain (mydomain.com)",
"price": 15,
"billing_period": "year",
"icon": "globe",
},
{
"code": "ssl_premium",
"name": "Premium SSL",
"description": "EV certificate for trust badges",
"price": 49,
"billing_period": "year",
"icon": "shield-check",
},
{
"code": "email",
"name": "Email Package",
"description": "Professional email addresses",
"price": 5,
"billing_period": "month",
"icon": "mail",
"options": [
{"quantity": 5, "price": 5},
{"quantity": 10, "price": 9},
{"quantity": 25, "price": 19},
],
},
]
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/platform/homepage-orion.html", "cms/platform/homepage-default.html",
context, context,
) )

View File

@@ -101,9 +101,17 @@ async def generic_content_page(
}, },
) )
# Resolve placeholders in store default pages ({{store_name}}, etc.)
page_content = page.content
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(page.content, store)
context = get_storefront_context(request, db=db, page=page)
context["page_content"] = page_content
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/storefront/content-page.html", "cms/storefront/content-page.html",
get_storefront_context(request, db=db, page=page), context,
) )

View File

@@ -31,6 +31,8 @@ from app.modules.cms.schemas.homepage_sections import (
HomepageSections, HomepageSections,
HomepageSectionsResponse, HomepageSectionsResponse,
PricingSection, PricingSection,
ProductCard,
ProductsSection,
# API schemas # API schemas
SectionUpdateRequest, SectionUpdateRequest,
# Translatable text # Translatable text
@@ -92,6 +94,8 @@ __all__ = [
"HeroSection", "HeroSection",
"FeatureCard", "FeatureCard",
"FeaturesSection", "FeaturesSection",
"ProductCard",
"ProductsSection",
"PricingSection", "PricingSection",
"CTASection", "CTASection",
"HomepageSections", "HomepageSections",

View File

@@ -77,6 +77,25 @@ class FeatureCard(BaseModel):
description: TranslatableText = Field(default_factory=TranslatableText) description: TranslatableText = Field(default_factory=TranslatableText)
class ProductCard(BaseModel):
"""Single product/offering card in products section."""
icon: str = ""
title: TranslatableText = Field(default_factory=TranslatableText)
description: TranslatableText = Field(default_factory=TranslatableText)
url: str = ""
badge: TranslatableText | None = None
class ProductsSection(BaseModel):
"""Product/offering showcase section (e.g. wizard.lu multi-product landing)."""
enabled: bool = True
title: TranslatableText = Field(default_factory=TranslatableText)
subtitle: TranslatableText | None = None
products: list[ProductCard] = Field(default_factory=list)
class FeaturesSection(BaseModel): class FeaturesSection(BaseModel):
"""Features section configuration.""" """Features section configuration."""
@@ -114,6 +133,7 @@ class HomepageSections(BaseModel):
"""Complete homepage sections structure.""" """Complete homepage sections structure."""
hero: HeroSection | None = None hero: HeroSection | None = None
products: ProductsSection | None = None
features: FeaturesSection | None = None features: FeaturesSection | None = None
pricing: PricingSection | None = None pricing: PricingSection | None = None
cta: CTASection | None = None cta: CTASection | None = None
@@ -139,6 +159,10 @@ class HomepageSections(BaseModel):
subtitle=make_translatable(languages), subtitle=make_translatable(languages),
buttons=[], buttons=[],
), ),
products=ProductsSection(
title=make_translatable(languages),
products=[],
),
features=FeaturesSection( features=FeaturesSection(
title=make_translatable(languages), title=make_translatable(languages),
features=[], features=[],
@@ -162,7 +186,7 @@ class HomepageSections(BaseModel):
class SectionUpdateRequest(BaseModel): class SectionUpdateRequest(BaseModel):
"""Request to update a single section.""" """Request to update a single section."""
section_name: str = Field(..., description="hero, features, pricing, or cta") section_name: str = Field(..., description="hero, products, features, pricing, or cta")
section_data: dict = Field(..., description="Section configuration") section_data: dict = Field(..., description="Section configuration")

View File

@@ -142,8 +142,8 @@ class ContentPageService:
db.query(ContentPage) db.query(ContentPage)
.filter( .filter(
and_( and_(
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == False, ContentPage.is_platform_page.is_(False),
*base_filters, *base_filters,
) )
) )
@@ -182,12 +182,12 @@ class ContentPageService:
filters = [ filters = [
ContentPage.platform_id == platform_id, ContentPage.platform_id == platform_id,
ContentPage.slug == slug, ContentPage.slug == slug,
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == True, ContentPage.is_platform_page.is_(True),
] ]
if not include_unpublished: if not include_unpublished:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published.is_(True))
page = db.query(ContentPage).filter(and_(*filters)).first() page = db.query(ContentPage).filter(and_(*filters)).first()
@@ -255,8 +255,8 @@ class ContentPageService:
db.query(ContentPage) db.query(ContentPage)
.filter( .filter(
and_( and_(
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == False, ContentPage.is_platform_page.is_(False),
*base_filters, *base_filters,
) )
) )
@@ -298,12 +298,12 @@ class ContentPageService:
""" """
filters = [ filters = [
ContentPage.platform_id == platform_id, ContentPage.platform_id == platform_id,
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == True, ContentPage.is_platform_page.is_(True),
] ]
if not include_unpublished: if not include_unpublished:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published.is_(True))
if footer_only: if footer_only:
filters.append(ContentPage.show_in_footer == True) filters.append(ContentPage.show_in_footer == True)
@@ -377,12 +377,12 @@ class ContentPageService:
""" """
filters = [ filters = [
ContentPage.platform_id == platform_id, ContentPage.platform_id == platform_id,
ContentPage.store_id is None, ContentPage.store_id.is_(None),
ContentPage.is_platform_page == False, ContentPage.is_platform_page.is_(False),
] ]
if not include_unpublished: if not include_unpublished:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published.is_(True))
return ( return (
db.query(ContentPage) db.query(ContentPage)
@@ -845,13 +845,13 @@ class ContentPageService:
filters.append(ContentPage.is_published == True) filters.append(ContentPage.is_published == True)
if page_tier == "platform": if page_tier == "platform":
filters.append(ContentPage.is_platform_page == True) filters.append(ContentPage.is_platform_page.is_(True))
filters.append(ContentPage.store_id is None) filters.append(ContentPage.store_id.is_(None))
elif page_tier == "store_default": elif page_tier == "store_default":
filters.append(ContentPage.is_platform_page == False) filters.append(ContentPage.is_platform_page.is_(False))
filters.append(ContentPage.store_id is None) filters.append(ContentPage.store_id.is_(None))
elif page_tier == "store_override": elif page_tier == "store_override":
filters.append(ContentPage.store_id is not None) filters.append(ContentPage.store_id.isnot(None))
return ( return (
db.query(ContentPage) db.query(ContentPage)
@@ -958,6 +958,34 @@ class ContentPageService:
if not success: if not success:
raise ContentPageNotFoundException(identifier=page_id) raise ContentPageNotFoundException(identifier=page_id)
# =========================================================================
# Placeholder Resolution (for store default pages)
# =========================================================================
@staticmethod
def resolve_placeholders(content: str, store) -> str:
"""
Replace {{store_name}}, {{store_email}}, {{store_phone}} placeholders
in store default page content with actual store values.
Args:
content: HTML content with placeholders
store: Store object with name, contact_email, phone attributes
Returns:
Content with placeholders replaced
"""
if not content or not store:
return content or ""
replacements = {
"{{store_name}}": store.name or "Our Store",
"{{store_email}}": getattr(store, "contact_email", "") or "",
"{{store_phone}}": getattr(store, "phone", "") or "",
}
for placeholder, value in replacements.items():
content = content.replace(placeholder, value)
return content
# ========================================================================= # =========================================================================
# Homepage Sections Management # Homepage Sections Management
# ========================================================================= # =========================================================================
@@ -1032,10 +1060,12 @@ class ContentPageService:
FeaturesSection, FeaturesSection,
HeroSection, HeroSection,
PricingSection, PricingSection,
ProductsSection,
) )
SECTION_SCHEMAS = { SECTION_SCHEMAS = {
"hero": HeroSection, "hero": HeroSection,
"products": ProductsSection,
"features": FeaturesSection, "features": FeaturesSection,
"pricing": PricingSection, "pricing": PricingSection,
"cta": CTASection, "cta": CTASection,

View File

@@ -187,6 +187,35 @@
</p> </p>
</div> </div>
</div> </div>
{# Available Placeholders (for store default pages) #}
{% set placeholders = [
('store_name', "The store's display name"),
('store_email', "The store's contact email"),
('store_phone', "The store's phone number"),
] %}
<div x-show="!form.store_id || form.store_id === 'null'" x-cloak
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center">
<span x-html="$icon('information-circle', 'w-4 h-4 mr-1.5')"></span>
Available Placeholders
</h4>
<p class="text-xs text-blue-700 dark:text-blue-400 mb-2">
Use these placeholders in store default pages. They will be automatically replaced with the store's actual information when displayed.
</p>
<div class="flex flex-wrap gap-2">
{% for name, description in placeholders %}
<code class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800/40 text-blue-800 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-800/60 transition-colors"
@click="navigator.clipboard.writeText('{% raw %}{{{% endraw %}{{ name }}{% raw %}}}{% endraw %}')"
title="{{ description }} — click to copy">
{% raw %}{{{% endraw %}{{ name }}{% raw %}}}{% endraw %}
</code>
{% endfor %}
</div>
<p class="text-xs text-blue-600 dark:text-blue-500 mt-2">
Click a placeholder to copy it to your clipboard.
</p>
</div>
</div> </div>
<!-- ══════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════ -->

View File

@@ -4,6 +4,7 @@
{# Import section partials #} {# Import section partials #}
{% from 'cms/platform/sections/_hero.html' import render_hero %} {% from 'cms/platform/sections/_hero.html' import render_hero %}
{% from 'cms/platform/sections/_products.html' import render_products %}
{% from 'cms/platform/sections/_features.html' import render_features %} {% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_pricing.html' import render_pricing %} {% from 'cms/platform/sections/_pricing.html' import render_pricing %}
{% from 'cms/platform/sections/_cta.html' import render_cta %} {% from 'cms/platform/sections/_cta.html' import render_cta %}
@@ -35,6 +36,11 @@
{{ render_hero(page.sections.hero, lang, default_lang) }} {{ render_hero(page.sections.hero, lang, default_lang) }}
{% endif %} {% endif %}
{# Products Section #}
{% if page.sections.products %}
{{ render_products(page.sections.products, lang, default_lang) }}
{% endif %}
{# Features Section #} {# Features Section #}
{% if page.sections.features %} {% if page.sections.features %}
{{ render_features(page.sections.features, lang, default_lang) }} {{ render_features(page.sections.features, lang, default_lang) }}

View File

@@ -1,427 +0,0 @@
{# app/templates/platform/homepage-orion.html #}
{# Orion Marketing Homepage - Letzshop OMS Platform #}
{% extends "platform/base.html" %}
{% from 'shared/macros/inputs.html' import toggle_switch %}
{% block title %}Orion - Order Management for Letzshop Sellers{% endblock %}
{% block meta_description %}Lightweight OMS for Letzshop stores. Manage orders, inventory, and invoicing. Start your 30-day free trial today.{% endblock %}
{% block content %}
<div x-data="homepageData()" class="bg-gray-50 dark:bg-gray-900">
{# =========================================================================
HERO SECTION
========================================================================= #}
<section class="relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
<div class="text-center">
{# Badge #}
<div class="inline-flex items-center px-4 py-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-full text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{{ _("cms.platform.hero.badge", trial_days=trial_days) }}
</div>
{# Headline #}
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6">
{{ _("cms.platform.hero.title") }}
</h1>
{# Subheadline #}
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10">
{{ _("cms.platform.hero.subtitle") }}
</p>
{# CTA Buttons #}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/signup"
class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105">
{{ _("cms.platform.hero.cta_trial") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
<a href="#find-shop"
class="inline-flex items-center justify-center px-8 py-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-semibold rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-500 transition-all">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
{{ _("cms.platform.hero.cta_find_shop") }}
</a>
</div>
</div>
</div>
{# Background Decoration #}
<div class="absolute inset-0 -z-10 overflow-hidden">
<div class="absolute -top-1/2 -right-1/4 w-96 h-96 bg-indigo-200 dark:bg-indigo-900/20 rounded-full blur-3xl opacity-50"></div>
<div class="absolute -bottom-1/2 -left-1/4 w-96 h-96 bg-purple-200 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50"></div>
</div>
</section>
{# =========================================================================
PRICING SECTION
========================================================================= #}
<section id="pricing" 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">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("cms.platform.pricing.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("cms.platform.pricing.subtitle", trial_days=trial_days) }}
</p>
{# Billing Toggle #}
<div class="flex justify-center mt-8">
{{ toggle_switch(
model='annual',
left_label=_("cms.platform.pricing.monthly"),
right_label=_("cms.platform.pricing.annual"),
right_badge=_("cms.platform.pricing.save_months")
) }}
</div>
</div>
{# Pricing Cards Grid #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{% for tier in tiers %}
<div class="relative bg-gray-50 dark:bg-gray-900 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
{# Popular Badge #}
{% if tier.is_popular %}
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
{{ _("cms.platform.pricing.most_popular") }}
</span>
</div>
{% endif %}
{# Tier Name #}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
{# Price #}
<div class="mb-6">
<template x-if="!annual">
<div>
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
</div>
</template>
<template x-if="annual">
<div>
{% if tier.price_annual %}
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}€</span>
<span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ tier.price_annual|int }}€ {{ _("cms.platform.pricing.per_year") }}
</div>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("cms.platform.pricing.custom") }}</span>
{% endif %}
</div>
</template>
</div>
{# Features List - Show all features, grey out unavailable #}
<ul class="space-y-2 mb-8 text-sm">
{# Orders #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
</li>
{# Products #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
</li>
{# Team Members #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
</li>
{# Letzshop Sync - always included #}
<li class="flex items-center text-gray-700 dark:text-gray-300">
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{{ _("cms.platform.pricing.letzshop_sync") }}
</li>
{# EU VAT Invoicing #}
<li class="flex items-center {% if 'invoice_eu_vat' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'invoice_eu_vat' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.eu_vat_invoicing") }}
</li>
{# Analytics Dashboard #}
<li class="flex items-center {% if 'analytics_dashboard' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'analytics_dashboard' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.analytics_dashboard") }}
</li>
{# API Access #}
<li class="flex items-center {% if 'api_access' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if 'api_access' in tier.features %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.api_access") }}
</li>
{# Multi-channel Integration - Enterprise only #}
<li class="flex items-center {% if tier.is_enterprise %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
{% if tier.is_enterprise %}
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
{% endif %}
{{ _("cms.platform.pricing.multi_channel") }}
</li>
</ul>
{# CTA Button #}
{% if tier.is_enterprise %}
<a href="mailto:sales@orion.lu?subject=Enterprise%20Plan%20Inquiry"
class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _("cms.platform.pricing.contact_sales") }}
</a>
{% else %}
<a href="/signup?tier={{ tier.code }}"
:href="'/signup?tier={{ tier.code }}&annual=' + annual"
class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}">
{{ _("cms.platform.pricing.start_trial") }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{# =========================================================================
ADD-ONS SECTION
========================================================================= #}
<section id="addons" 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">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("cms.platform.addons.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ _("cms.platform.addons.subtitle") }}
</p>
</div>
{# Add-ons Grid #}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{% for addon in addons %}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
{# Icon #}
<div class="w-14 h-14 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center mb-6">
{% if addon.icon == 'globe' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
{% elif addon.icon == 'shield-check' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% elif addon.icon == 'mail' %}
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
{% endif %}
</div>
{# Name & Description #}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ addon.name }}</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ addon.description }}</p>
{# Price #}
<div class="flex items-baseline">
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ addon.price }}€</span>
<span class="text-gray-500 dark:text-gray-400 ml-1">/{{ addon.billing_period }}</span>
</div>
{# Options for email packages #}
{% if addon.options %}
<div class="mt-4 space-y-2">
{% for opt in addon.options %}
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ opt.quantity }} addresses: {{ opt.price }}€/month
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{# =========================================================================
LETZSHOP STORE FINDER
========================================================================= #}
<section id="find-shop" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section Header #}
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ _("cms.platform.find_shop.title") }}
</h2>
<p class="text-lg text-gray-600 dark:text-gray-400">
{{ _("cms.platform.find_shop.subtitle") }}
</p>
</div>
{# Search Form #}
<div class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-8 border border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row gap-4">
<input
type="text"
x-model="shopUrl"
placeholder="{{ _('cms.platform.find_shop.placeholder') }}"
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<button
@click="lookupStore()"
:disabled="loading"
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center">
<template x-if="loading">
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
</template>
{{ _("cms.platform.find_shop.button") }}
</button>
</div>
{# Result #}
<template x-if="storeResult">
<div class="mt-6 p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<template x-if="storeResult.found">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="storeResult.store.name"></h3>
<a :href="storeResult.store.letzshop_url" target="_blank" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline" x-text="storeResult.store.letzshop_url"></a>
</div>
<template x-if="!storeResult.store.is_claimed">
<a :href="'/signup?letzshop=' + storeResult.store.slug"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors">
{{ _("cms.platform.find_shop.claim_shop") }}
</a>
</template>
<template x-if="storeResult.store.is_claimed">
<span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg">
{{ _("cms.platform.find_shop.already_claimed") }}
</span>
</template>
</div>
</template>
<template x-if="!storeResult.found">
<div class="text-center text-gray-600 dark:text-gray-400">
<p x-text="storeResult.error || 'Shop not found. Please check your URL and try again.'"></p>
</div>
</template>
</div>
</template>
{# Help Text #}
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ _("cms.platform.find_shop.no_account") }} <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("cms.platform.find_shop.signup_letzshop") }}</a>{{ _("cms.platform.find_shop.then_connect") }}
</p>
</div>
</div>
</section>
{# =========================================================================
FINAL CTA SECTION
========================================================================= #}
<section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
{{ _("cms.platform.cta.title") }}
</h2>
<p class="text-xl text-indigo-100 mb-10">
{{ _("cms.platform.cta.subtitle", trial_days=trial_days) }}
</p>
<a href="/signup"
class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105">
{{ _("cms.platform.cta.button") }}
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
</div>
</section>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function homepageData() {
return {
annual: false,
shopUrl: '',
storeResult: null,
loading: false,
async lookupStore() {
if (!this.shopUrl.trim()) return;
this.loading = true;
this.storeResult = null;
try {
const response = await fetch('/api/v1/platform/letzshop-stores/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.shopUrl })
});
this.storeResult = await response.json();
} catch (error) {
console.error('Lookup error:', error);
this.storeResult = { found: false, error: 'Failed to lookup. Please try again.' };
} finally {
this.loading = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{# app/templates/platform/sections/_products.html #}
{# Products/offerings section for multi-product platforms (e.g. wizard.lu) #}
{#
Parameters:
- products: ProductsSection object (or dict)
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_products(products, lang, default_lang) %}
{% if products and products.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 = products.title.translations.get(lang) or products.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 %}
{% if products.subtitle and products.subtitle.translations %}
{% set subtitle = products.subtitle.translations.get(lang) or products.subtitle.translations.get(default_lang) %}
{% if subtitle %}
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{{ subtitle }}
</p>
{% endif %}
{% endif %}
</div>
{# Product cards #}
{% if products.products %}
<div class="grid grid-cols-1 md:grid-cols-{{ [products.products|length, 3]|min }} gap-8">
{% for product in products.products %}
<div class="relative bg-gray-50 dark:bg-gray-700 rounded-2xl p-8 border-2 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 transition-all hover:shadow-xl group">
{# Badge #}
{% if product.badge and product.badge.translations %}
{% set badge_text = product.badge.translations.get(lang) or product.badge.translations.get(default_lang) %}
{% if badge_text %}
<div class="absolute -top-3 right-4">
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
{{ badge_text }}
</span>
</div>
{% endif %}
{% endif %}
{# Icon #}
{% if product.icon %}
<div class="w-14 h-14 mb-6 rounded-xl gradient-primary flex items-center justify-center">
<span x-html="typeof $icon !== 'undefined' ? $icon('{{ product.icon }}', 'w-7 h-7 text-white') : ''"></span>
</div>
{% endif %}
{# Title #}
{% set product_title = product.title.translations.get(lang) or product.title.translations.get(default_lang) or '' %}
{% if product_title %}
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
{{ product_title }}
</h3>
{% endif %}
{# Description #}
{% set product_desc = product.description.translations.get(lang) or product.description.translations.get(default_lang) or '' %}
{% if product_desc %}
<p class="text-gray-600 dark:text-gray-400 mb-6">
{{ product_desc }}
</p>
{% endif %}
{# CTA Link #}
{% if product.url %}
<a href="{{ product.url }}"
class="inline-flex items-center text-indigo-600 dark:text-indigo-400 font-semibold hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors group-hover:translate-x-1 transform transition-transform">
{% set link_text = product_title or 'Learn More' %}
Learn More
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -42,17 +42,16 @@
{% endif %} {% endif %}
</div> </div>
{# Content #} {# Content — use page_content (with resolved placeholders) when available #}
{% set content = page_content if page_content is defined and page_content else page.content %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
<div class="prose prose-lg dark:prose-invert max-w-none"> <div class="prose prose-lg dark:prose-invert max-w-none">
{% if page.content_format == 'markdown' %} {% if page.content_format == 'markdown' %}
{# Markdown content - future enhancement: render with markdown library #}
<div class="markdown-content"> <div class="markdown-content">
{{ page.content | safe }}{# sanitized: CMS content #} {{ content | safe }}{# sanitized: CMS content #}
</div> </div>
{% else %} {% else %}
{# HTML content (default) #} {{ content | safe }}{# sanitized: CMS content #}
{{ page.content | safe }}{# sanitized: CMS content #}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -63,6 +63,11 @@ from app.modules.contracts.metrics import (
MetricsProviderProtocol, MetricsProviderProtocol,
MetricValue, MetricValue,
) )
from app.modules.contracts.onboarding import (
OnboardingProviderProtocol,
OnboardingStepDefinition,
OnboardingStepStatus,
)
from app.modules.contracts.widgets import ( from app.modules.contracts.widgets import (
BreakdownWidget, BreakdownWidget,
DashboardWidget, DashboardWidget,
@@ -92,6 +97,10 @@ __all__ = [
"MetricValue", "MetricValue",
"MetricsContext", "MetricsContext",
"MetricsProviderProtocol", "MetricsProviderProtocol",
# Onboarding protocols
"OnboardingStepDefinition",
"OnboardingStepStatus",
"OnboardingProviderProtocol",
# Widget protocols # Widget protocols
"WidgetContext", "WidgetContext",
"WidgetListItem", "WidgetListItem",

View File

@@ -0,0 +1,154 @@
# app/modules/contracts/onboarding.py
"""
Onboarding provider protocol for module-driven post-signup onboarding.
Each module defines its own onboarding steps (what needs to be configured)
and provides completion checks. The core module's OnboardingAggregator
discovers and aggregates all providers into a dashboard checklist.
Benefits:
- Modules own their onboarding steps (billing module doesn't need to know)
- Steps appear/disappear based on which modules are enabled
- Easy to add new steps (just implement protocol in your module)
- Dashboard banner guides merchants without blocking signup
Usage:
# 1. Implement the protocol in your module
class MarketplaceOnboardingProvider:
@property
def onboarding_category(self) -> str:
return "marketplace"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="marketplace.connect_api",
title_key="onboarding.marketplace.connect_api.title",
description_key="onboarding.marketplace.connect_api.description",
icon="plug",
route_template="/store/{store_code}/letzshop",
order=200,
)
]
def is_step_completed(self, db, store_id, step_key) -> bool:
...
# 2. Register in module definition
def _get_onboarding_provider():
from app.modules.marketplace.services.marketplace_onboarding import (
marketplace_onboarding_provider,
)
return marketplace_onboarding_provider
marketplace_module = ModuleDefinition(
code="marketplace",
onboarding_provider=_get_onboarding_provider,
# ...
)
# 3. Steps appear automatically in dashboard when module is enabled
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from sqlalchemy.orm import Session
@dataclass
class OnboardingStepDefinition:
"""
Definition of a single onboarding step.
Attributes:
key: Unique identifier (e.g., "marketplace.connect_api")
Format: "{module}.{step_name}" for consistency
title_key: i18n key for display title
description_key: i18n key for description text
icon: Lucide icon name for UI display (e.g., "plug", "package")
route_template: URL template with {store_code} placeholder
order: Display sort order (lower = first). Default 100.
category: Grouping category (typically matches module code)
"""
key: str
title_key: str
description_key: str
icon: str
route_template: str
order: int = 100
category: str = ""
@dataclass
class OnboardingStepStatus:
"""
An onboarding step paired with its completion status.
Attributes:
step: The step definition
completed: Whether the step has been completed
"""
step: OnboardingStepDefinition
completed: bool
@runtime_checkable
class OnboardingProviderProtocol(Protocol):
"""
Protocol for modules that provide onboarding steps.
Each module implements this to declare what setup steps are needed
after signup. The core module's OnboardingAggregator discovers and
aggregates all providers into a dashboard checklist banner.
Implementation Notes:
- Providers should be stateless (all data via db session)
- Return empty list from get_onboarding_steps() if no steps needed
- is_step_completed() should be efficient (called per step per page load)
- Use consistent key format: "{category}.{step_name}"
"""
@property
def onboarding_category(self) -> str:
"""
Category name for this provider's onboarding steps.
Should match the module code (e.g., "marketplace", "tenancy").
"""
...
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
"""
Get onboarding step definitions provided by this module.
Returns:
List of OnboardingStepDefinition objects
"""
...
def is_step_completed(
self, db: "Session", store_id: int, step_key: str
) -> bool:
"""
Check if a specific onboarding step is completed for a store.
Args:
db: Database session for queries
store_id: ID of the store to check
step_key: The step key to check (from OnboardingStepDefinition.key)
Returns:
True if the step is completed
"""
...
__all__ = [
"OnboardingStepDefinition",
"OnboardingStepStatus",
"OnboardingProviderProtocol",
]

View File

@@ -24,6 +24,7 @@ from app.modules.core.schemas.dashboard import (
StoreProductStats, StoreProductStats,
StoreRevenueStats, StoreRevenueStats,
) )
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
from app.modules.core.services.stats_aggregator import stats_aggregator from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.exceptions import StoreNotActiveException from app.modules.tenancy.exceptions import StoreNotActiveException
from app.modules.tenancy.schemas.auth import UserContext from app.modules.tenancy.schemas.auth import UserContext
@@ -124,3 +125,27 @@ def get_store_dashboard_stats(
this_month=float(revenue_this_month), this_month=float(revenue_this_month),
), ),
) )
@store_dashboard_router.get("/onboarding")
def get_onboarding_status(
request: Request,
current_user: UserContext = Depends(get_current_store_api),
platform=Depends(require_platform),
db: Session = Depends(get_db),
):
"""
Get onboarding checklist status for the current store.
Returns steps from all enabled modules with completion status,
progress percentage, and whether all steps are completed.
"""
store_id = current_user.token_store_id
store = store_service.get_store_by_id(db, store_id)
return onboarding_aggregator.get_onboarding_summary(
db=db,
store_id=store_id,
platform_id=platform.id,
store_code=store.store_code,
)

View File

@@ -0,0 +1,157 @@
# app/modules/core/services/onboarding_aggregator.py
"""
Onboarding aggregator service for collecting onboarding steps from all modules.
This service lives in core because the dashboard onboarding banner is core
functionality. It discovers OnboardingProviders from all enabled modules and
provides a unified interface for the dashboard checklist.
Usage:
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
summary = onboarding_aggregator.get_onboarding_summary(
db=db, store_id=123, platform_id=1, store_code="my-store"
)
# Returns: {steps, total_steps, completed_steps, progress_percentage, all_completed}
"""
import logging
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import (
OnboardingProviderProtocol,
OnboardingStepStatus,
)
if TYPE_CHECKING:
from app.modules.base import ModuleDefinition
logger = logging.getLogger(__name__)
class OnboardingAggregatorService:
"""
Aggregates onboarding steps from all module providers.
Discovers OnboardingProviders from enabled modules and provides
a unified interface for the dashboard onboarding banner.
"""
def _get_enabled_providers(
self, db: Session, platform_id: int
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
"""
Get onboarding providers from enabled modules.
Returns:
List of (module, provider) tuples for enabled modules with providers
"""
from app.modules.registry import MODULES
from app.modules.service import module_service
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
for module in MODULES.values():
if not module.has_onboarding_provider():
continue
# Core modules are always enabled, check others
if not module.is_core:
try:
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
except Exception as e:
logger.warning(
f"Failed to check if module {module.code} is enabled: {e}"
)
continue
try:
provider = module.get_onboarding_provider_instance()
if provider is not None:
providers.append((module, provider))
except Exception as e:
logger.warning(
f"Failed to get onboarding provider for module {module.code}: {e}"
)
return providers
def get_onboarding_steps(
self, db: Session, store_id: int, platform_id: int
) -> list[OnboardingStepStatus]:
"""
Get all onboarding steps with completion status, sorted by order.
Returns:
Sorted list of OnboardingStepStatus objects
"""
providers = self._get_enabled_providers(db, platform_id)
steps: list[OnboardingStepStatus] = []
for module, provider in providers:
try:
step_defs = provider.get_onboarding_steps()
for step_def in step_defs:
try:
completed = provider.is_step_completed(db, store_id, step_def.key)
except Exception as e:
logger.warning(
f"Failed to check step {step_def.key} from {module.code}: {e}"
)
completed = False
steps.append(OnboardingStepStatus(step=step_def, completed=completed))
except Exception as e:
logger.warning(
f"Failed to get onboarding steps from module {module.code}: {e}"
)
steps.sort(key=lambda s: s.step.order)
return steps
def get_onboarding_summary(
self,
db: Session,
store_id: int,
platform_id: int,
store_code: str = "",
) -> dict[str, Any]:
"""
Get onboarding summary for the dashboard API.
Returns:
Dict with: steps[], total_steps, completed_steps, progress_percentage, all_completed
"""
step_statuses = self.get_onboarding_steps(db, store_id, platform_id)
total = len(step_statuses)
completed = sum(1 for s in step_statuses if s.completed)
steps_data = []
for status in step_statuses:
route = status.step.route_template.replace("{store_code}", store_code)
steps_data.append({
"key": status.step.key,
"title_key": status.step.title_key,
"description_key": status.step.description_key,
"icon": status.step.icon,
"route": route,
"completed": status.completed,
"category": status.step.category,
})
return {
"steps": steps_data,
"total_steps": total,
"completed_steps": completed,
"progress_percentage": round((completed / total * 100) if total > 0 else 0),
"all_completed": completed == total and total > 0,
}
# Singleton instance
onboarding_aggregator = OnboardingAggregatorService()
__all__ = ["OnboardingAggregatorService", "onboarding_aggregator"]

View File

@@ -10,6 +10,47 @@ const storeDashLog = window.LogConfig.loggers.dashboard ||
storeDashLog.info('Loading...'); storeDashLog.info('Loading...');
storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data); storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
/**
* Onboarding banner component.
* Fetches onboarding steps from API, supports session-scoped dismiss.
*/
function onboardingBanner() {
return {
visible: false,
steps: [],
totalSteps: 0,
completedSteps: 0,
progressPercentage: 0,
async init() {
// Check session-scoped dismiss
if (sessionStorage.getItem('onboarding_dismissed')) {
return;
}
try {
const response = await apiClient.get('/store/dashboard/onboarding');
this.steps = response.steps || [];
this.totalSteps = response.total_steps || 0;
this.completedSteps = response.completed_steps || 0;
this.progressPercentage = response.progress_percentage || 0;
// Show banner only if there are incomplete steps
if (this.totalSteps > 0 && !response.all_completed) {
this.visible = true;
}
} catch (error) {
storeDashLog.error('Failed to load onboarding status', error);
}
},
dismiss() {
sessionStorage.setItem('onboarding_dismissed', 'true');
this.visible = false;
}
};
}
function storeDashboard() { function storeDashboard() {
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called'); storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data); storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);

View File

@@ -8,12 +8,15 @@
{% block alpine_data %}storeDashboard(){% endblock %} {% block alpine_data %}storeDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning %} {% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
{% block content %} {% block content %}
<!-- Email Settings Warning --> <!-- Email Settings Warning -->
{{ email_settings_warning() }} {{ email_settings_warning() }}
<!-- Onboarding Banner -->
{{ onboarding_banner() }}
<!-- Limit Warnings --> <!-- Limit Warnings -->
{{ limit_warning("orders") }} {{ limit_warning("orders") }}
{{ limit_warning("products") }} {{ limit_warning("products") }}

View File

@@ -0,0 +1,215 @@
# app/modules/core/tests/integration/test_onboarding_routes.py
"""
Integration tests for the onboarding API endpoint.
Tests the store dashboard onboarding endpoint at:
GET /api/v1/store/dashboard/onboarding
Verifies:
- Endpoint returns onboarding steps with completion status
- Endpoint requires store authentication
- Response structure matches expected schema
"""
import uuid
import pytest
from app.api.deps import get_current_store_api
from app.modules.tenancy.models import Merchant, Platform, Store, User
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.schemas.auth import UserContext
from main import app
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def onb_owner(db):
"""Create a store owner user for onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"onbroute_{uuid.uuid4().hex[:8]}@test.com",
username=f"onbroute_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def onb_platform(db):
"""Create a platform for onboarding tests."""
platform = Platform(
code=f"onbp_{uuid.uuid4().hex[:8]}",
name="Onboarding Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def onb_merchant(db, onb_owner):
"""Create a merchant for onboarding tests."""
merchant = Merchant(
name="Onboarding Route Test Merchant",
owner_user_id=onb_owner.id,
contact_email=onb_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def onb_store(db, onb_merchant):
"""Create a store for onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=onb_merchant.id,
store_code=f"ONBROUTE_{uid.upper()}",
subdomain=f"onbroute{uid.lower()}",
name="Onboarding Route Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def onb_store_platform(db, onb_store, onb_platform):
"""Link store to platform."""
sp = StorePlatform(
store_id=onb_store.id,
platform_id=onb_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
@pytest.fixture
def onb_auth(onb_owner, onb_store):
"""Override auth dependency for store API auth."""
user_context = UserContext(
id=onb_owner.id,
email=onb_owner.email,
username=onb_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=onb_store.id,
token_store_code=onb_store.store_code,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_api] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_api, None)
# ============================================================================
# Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.core
class TestOnboardingEndpoint:
"""Tests for GET /api/v1/store/dashboard/onboarding."""
def test_returns_onboarding_summary(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Endpoint returns onboarding summary with expected structure."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
assert "steps" in data
assert "total_steps" in data
assert "completed_steps" in data
assert "progress_percentage" in data
assert "all_completed" in data
assert isinstance(data["steps"], list)
assert isinstance(data["total_steps"], int)
assert isinstance(data["completed_steps"], int)
def test_includes_tenancy_step(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Response includes the tenancy customize_store step (always present)."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
assert response.status_code == 200
data = response.json()
step_keys = [s["key"] for s in data["steps"]]
assert "tenancy.customize_store" in step_keys
def test_step_data_has_required_fields(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Each step has key, title_key, description_key, icon, route, completed."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "key" in step
assert "title_key" in step
assert "description_key" in step
assert "icon" in step
assert "route" in step
assert "completed" in step
assert isinstance(step["completed"], bool)
def test_routes_contain_store_code(
self, client, db, onb_auth, onb_store, onb_store_platform
):
"""Step routes have {store_code} replaced with the actual store code."""
response = client.get(
"/api/v1/store/dashboard/onboarding",
headers=onb_auth,
)
data = response.json()
for step in data["steps"]:
assert "{store_code}" not in step["route"]
if "/store/" in step["route"]:
assert onb_store.store_code in step["route"]
def test_requires_auth(self, client):
"""Returns 401 without authentication."""
app.dependency_overrides.pop(get_current_store_api, None)
response = client.get(
"/api/v1/store/dashboard/onboarding",
)
assert response.status_code == 401

View File

@@ -0,0 +1,275 @@
# app/modules/core/tests/unit/test_onboarding_aggregator.py
"""Unit tests for OnboardingAggregatorService."""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.contracts.onboarding import (
OnboardingStepDefinition,
OnboardingStepStatus,
)
from app.modules.core.services.onboarding_aggregator import OnboardingAggregatorService
def _make_step(key: str, order: int = 100, category: str = "test") -> OnboardingStepDefinition:
return OnboardingStepDefinition(
key=key,
title_key=f"{key}.title",
description_key=f"{key}.desc",
icon="circle",
route_template="/store/{store_code}/test",
order=order,
category=category,
)
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSteps:
"""Tests for OnboardingAggregatorService.get_onboarding_steps."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_aggregates_steps_from_multiple_providers(self, db):
"""Collects steps from all enabled module providers."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 2
assert steps[0].step.key == "tenancy.customize"
assert steps[0].completed is True
assert steps[1].step.key == "marketplace.connect"
assert steps[1].completed is False
def test_steps_sorted_by_order(self, db):
"""Steps are returned sorted by order regardless of provider order."""
step_high = _make_step("loyalty.program", order=300)
step_low = _make_step("tenancy.customize", order=100)
provider_high = MagicMock()
provider_high.onboarding_category = "loyalty"
provider_high.get_onboarding_steps.return_value = [step_high]
provider_high.is_step_completed.return_value = False
provider_low = MagicMock()
provider_low.onboarding_category = "tenancy"
provider_low.get_onboarding_steps.return_value = [step_low]
provider_low.is_step_completed.return_value = False
module_high = MagicMock()
module_high.code = "loyalty"
module_low = MagicMock()
module_low.code = "tenancy"
# Provide high-order first
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_high, provider_high), (module_low, provider_low)],
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps[0].step.key == "tenancy.customize"
assert steps[1].step.key == "loyalty.program"
def test_empty_when_no_providers(self, db):
"""Returns empty list when no modules have onboarding providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_provider_error_gracefully(self, db):
"""Skips a provider that raises an exception."""
provider = MagicMock()
provider.onboarding_category = "broken"
provider.get_onboarding_steps.side_effect = RuntimeError("DB error")
module = MagicMock()
module.code = "broken"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert steps == []
def test_handles_step_completion_check_error(self, db):
"""Marks step as incomplete when completion check raises."""
step = _make_step("test.step")
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.side_effect = RuntimeError("query error")
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
assert len(steps) == 1
assert steps[0].completed is False
@pytest.mark.unit
@pytest.mark.core
class TestOnboardingAggregatorSummary:
"""Tests for OnboardingAggregatorService.get_onboarding_summary."""
def setup_method(self):
self.service = OnboardingAggregatorService()
def test_summary_with_mixed_completion(self, db):
"""Returns correct progress for partially completed onboarding."""
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
step_c = _make_step("marketplace.import", order=210, category="marketplace")
provider_a = MagicMock()
provider_a.onboarding_category = "tenancy"
provider_a.get_onboarding_steps.return_value = [step_a]
provider_a.is_step_completed.return_value = True
provider_b = MagicMock()
provider_b.onboarding_category = "marketplace"
provider_b.get_onboarding_steps.return_value = [step_b, step_c]
provider_b.is_step_completed.return_value = False
module_a = MagicMock()
module_a.code = "tenancy"
module_b = MagicMock()
module_b.code = "marketplace"
with patch.object(
self.service,
"_get_enabled_providers",
return_value=[(module_a, provider_a), (module_b, provider_b)],
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MYSTORE"
)
assert summary["total_steps"] == 3
assert summary["completed_steps"] == 1
assert summary["progress_percentage"] == 33
assert summary["all_completed"] is False
assert len(summary["steps"]) == 3
def test_summary_all_completed(self, db):
"""Returns all_completed=True when every step is done."""
step = _make_step("tenancy.customize")
provider = MagicMock()
provider.onboarding_category = "tenancy"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "tenancy"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["all_completed"] is True
assert summary["progress_percentage"] == 100
def test_summary_empty_providers(self, db):
"""Returns zeros and all_completed=False when no providers."""
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="TEST"
)
assert summary["total_steps"] == 0
assert summary["completed_steps"] == 0
assert summary["progress_percentage"] == 0
assert summary["all_completed"] is False
def test_summary_replaces_store_code_in_routes(self, db):
"""Route templates have {store_code} replaced with actual store code."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="circle",
route_template="/store/{store_code}/settings",
order=100,
category="test",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = False
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="MY_STORE"
)
assert summary["steps"][0]["route"] == "/store/MY_STORE/settings"
def test_summary_step_data_includes_all_fields(self, db):
"""Each step in summary includes key, title_key, description_key, icon, route, completed, category."""
step = OnboardingStepDefinition(
key="test.step",
title_key="test.title",
description_key="test.desc",
icon="settings",
route_template="/store/{store_code}/test",
order=100,
category="test_cat",
)
provider = MagicMock()
provider.onboarding_category = "test"
provider.get_onboarding_steps.return_value = [step]
provider.is_step_completed.return_value = True
module = MagicMock()
module.code = "test"
with patch.object(
self.service, "_get_enabled_providers", return_value=[(module, provider)]
):
summary = self.service.get_onboarding_summary(
db, store_id=1, platform_id=1, store_code="S1"
)
step_data = summary["steps"][0]
assert step_data["key"] == "test.step"
assert step_data["title_key"] == "test.title"
assert step_data["description_key"] == "test.desc"
assert step_data["icon"] == "settings"
assert step_data["route"] == "/store/S1/test"
assert step_data["completed"] is True
assert step_data["category"] == "test_cat"

View File

@@ -58,6 +58,15 @@ def _get_feature_provider():
return loyalty_feature_provider return loyalty_feature_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_onboarding import (
loyalty_onboarding_provider,
)
return loyalty_onboarding_provider
# Loyalty module definition # Loyalty module definition
loyalty_module = ModuleDefinition( loyalty_module = ModuleDefinition(
code="loyalty", code="loyalty",
@@ -260,6 +269,8 @@ loyalty_module = ModuleDefinition(
], ],
# Feature provider for billing feature gating # Feature provider for billing feature gating
feature_provider=_get_feature_provider, feature_provider=_get_feature_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
) )

View File

@@ -0,0 +1,61 @@
# app/modules/loyalty/services/loyalty_onboarding.py
"""
Onboarding provider for the loyalty module.
Provides the "Create your first loyalty program" step.
Completed when at least 1 LoyaltyProgram exists for the store's merchant.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import OnboardingStepDefinition
logger = logging.getLogger(__name__)
class LoyaltyOnboardingProvider:
"""Onboarding provider for loyalty module."""
@property
def onboarding_category(self) -> str:
return "loyalty"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="loyalty.create_program",
title_key="onboarding.loyalty.create_program.title",
description_key="onboarding.loyalty.create_program.description",
icon="gift",
route_template="/store/{store_code}/loyalty/programs",
order=300,
category="loyalty",
),
]
def is_step_completed(
self, db: Session, store_id: int, step_key: str
) -> bool:
if step_key != "loyalty.create_program":
return False
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram
from app.modules.tenancy.models.store import Store
# Programs belong to merchant, not store — join through store
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return False
count = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
.limit(1)
.count()
)
return count > 0
loyalty_onboarding_provider = LoyaltyOnboardingProvider()

View File

@@ -0,0 +1,125 @@
# app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
"""Unit tests for LoyaltyOnboardingProvider."""
import uuid
import pytest
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram, LoyaltyType
from app.modules.loyalty.services.loyalty_onboarding import LoyaltyOnboardingProvider
from app.modules.tenancy.models import Merchant, Store, User
@pytest.fixture
def loy_owner(db):
"""Create a store owner for loyalty onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"loyonb_{uuid.uuid4().hex[:8]}@test.com",
username=f"loyonb_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def loy_merchant(db, loy_owner):
"""Create a merchant for loyalty onboarding tests."""
merchant = Merchant(
name="Loyalty Onboarding Merchant",
owner_user_id=loy_owner.id,
contact_email=loy_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def loy_store(db, loy_merchant):
"""Create a store (no loyalty program) for loyalty onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=loy_merchant.id,
store_code=f"LOYONB_{uid.upper()}",
subdomain=f"loyonb{uid.lower()}",
name="Loyalty Onboarding Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.loyalty
class TestLoyaltyOnboardingProvider:
"""Tests for LoyaltyOnboardingProvider."""
def setup_method(self):
self.provider = LoyaltyOnboardingProvider()
def test_category(self):
"""Returns 'loyalty' as the onboarding category."""
assert self.provider.onboarding_category == "loyalty"
def test_get_onboarding_steps_returns_one_step(self):
"""Returns exactly one step: create_program."""
steps = self.provider.get_onboarding_steps()
assert len(steps) == 1
assert steps[0].key == "loyalty.create_program"
assert steps[0].order == 300
def test_incomplete_without_program(self, db, loy_store):
"""Step is incomplete when merchant has no loyalty programs."""
result = self.provider.is_step_completed(
db, loy_store.id, "loyalty.create_program"
)
assert result is False
def test_complete_with_program(self, db, loy_store, loy_merchant):
"""Step is complete when merchant has at least one loyalty program."""
program = LoyaltyProgram(
merchant_id=loy_merchant.id,
loyalty_type=LoyaltyType.STAMPS.value,
stamps_target=10,
cooldown_minutes=0,
max_daily_stamps=10,
require_staff_pin=False,
card_name="Test Card",
card_color="#FF0000",
is_active=True,
)
db.add(program)
db.commit()
result = self.provider.is_step_completed(
db, loy_store.id, "loyalty.create_program"
)
assert result is True
def test_nonexistent_store_returns_false(self, db):
"""Returns False for a store ID that doesn't exist."""
result = self.provider.is_step_completed(
db, 999999, "loyalty.create_program"
)
assert result is False
def test_unknown_step_key_returns_false(self, db, loy_store):
"""Returns False for an unrecognized step key."""
result = self.provider.is_step_completed(
db, loy_store.id, "loyalty.unknown_step"
)
assert result is False

View File

@@ -59,6 +59,15 @@ def _get_feature_provider():
return marketplace_feature_provider return marketplace_feature_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.marketplace.services.marketplace_onboarding import (
marketplace_onboarding_provider,
)
return marketplace_onboarding_provider
# Marketplace module definition # Marketplace module definition
marketplace_module = ModuleDefinition( marketplace_module = ModuleDefinition(
code="marketplace", code="marketplace",
@@ -186,6 +195,8 @@ marketplace_module = ModuleDefinition(
widget_provider=_get_widget_provider, widget_provider=_get_widget_provider,
# Feature provider for feature flags # Feature provider for feature flags
feature_provider=_get_feature_provider, feature_provider=_get_feature_provider,
# Onboarding provider for post-signup checklist
onboarding_provider=_get_onboarding_provider,
) )

View File

@@ -95,15 +95,7 @@ async def store_marketplace_page(
""" """
Render marketplace import page. Render marketplace import page.
JavaScript loads import jobs and products via API. JavaScript loads import jobs and products via API.
Redirects to onboarding if not completed.
""" """
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse( return templates.TemplateResponse(
"marketplace/store/marketplace.html", "marketplace/store/marketplace.html",
get_store_context(request, db, current_user, store_code), get_store_context(request, db, current_user, store_code),
@@ -127,15 +119,7 @@ async def store_letzshop_page(
""" """
Render Letzshop integration page. Render Letzshop integration page.
JavaScript loads orders, credentials status, and handles fulfillment operations. JavaScript loads orders, credentials status, and handles fulfillment operations.
Redirects to onboarding if not completed.
""" """
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(
url=f"/store/{store_code}/onboarding",
status_code=302,
)
return templates.TemplateResponse( return templates.TemplateResponse(
"marketplace/store/letzshop.html", "marketplace/store/letzshop.html",
get_store_context(request, db, current_user, store_code), get_store_context(request, db, current_user, store_code),

View File

@@ -0,0 +1,79 @@
# app/modules/marketplace/services/marketplace_onboarding.py
"""
Onboarding provider for the marketplace module.
Provides two onboarding steps:
1. Connect Letzshop API - completed when StoreLetzshopCredentials exists with api_key
2. Import products - completed when at least 1 product exists in catalog
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import OnboardingStepDefinition
logger = logging.getLogger(__name__)
class MarketplaceOnboardingProvider:
"""Onboarding provider for marketplace module."""
@property
def onboarding_category(self) -> str:
return "marketplace"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="marketplace.connect_api",
title_key="onboarding.marketplace.connect_api.title",
description_key="onboarding.marketplace.connect_api.description",
icon="plug",
route_template="/store/{store_code}/letzshop",
order=200,
category="marketplace",
),
OnboardingStepDefinition(
key="marketplace.import_products",
title_key="onboarding.marketplace.import_products.title",
description_key="onboarding.marketplace.import_products.description",
icon="package",
route_template="/store/{store_code}/marketplace",
order=210,
category="marketplace",
),
]
def is_step_completed(
self, db: Session, store_id: int, step_key: str
) -> bool:
if step_key == "marketplace.connect_api":
return self._has_letzshop_credentials(db, store_id)
if step_key == "marketplace.import_products":
return self._has_products(db, store_id)
return False
def _has_letzshop_credentials(self, db: Session, store_id: int) -> bool:
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
creds = (
db.query(StoreLetzshopCredentials)
.filter(StoreLetzshopCredentials.store_id == store_id)
.first()
)
return bool(creds and creds.api_key_encrypted)
def _has_products(self, db: Session, store_id: int) -> bool:
from app.modules.catalog.models.product import Product
count = (
db.query(Product)
.filter(Product.store_id == store_id)
.limit(1)
.count()
)
return count > 0
marketplace_onboarding_provider = MarketplaceOnboardingProvider()

View File

@@ -0,0 +1,140 @@
# app/modules/marketplace/tests/unit/test_marketplace_onboarding.py
"""Unit tests for MarketplaceOnboardingProvider."""
import uuid
import pytest
from app.modules.marketplace.services.marketplace_onboarding import (
MarketplaceOnboardingProvider,
)
from app.modules.tenancy.models import Merchant, Store, User
@pytest.fixture
def mp_owner(db):
"""Create a store owner for marketplace onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"mpowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def mp_merchant(db, mp_owner):
"""Create a merchant for marketplace onboarding tests."""
merchant = Merchant(
name="MP Onboarding Merchant",
owner_user_id=mp_owner.id,
contact_email=mp_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def mp_store(db, mp_merchant):
"""Create a store for marketplace onboarding tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=mp_merchant.id,
store_code=f"MPSTORE_{uid.upper()}",
subdomain=f"mpstore{uid.lower()}",
name="MP Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.marketplace
class TestMarketplaceOnboardingProvider:
"""Tests for MarketplaceOnboardingProvider."""
def setup_method(self):
self.provider = MarketplaceOnboardingProvider()
def test_category(self):
"""Returns 'marketplace' as the onboarding category."""
assert self.provider.onboarding_category == "marketplace"
def test_get_onboarding_steps_returns_two_steps(self):
"""Returns two steps: connect_api and import_products."""
steps = self.provider.get_onboarding_steps()
assert len(steps) == 2
assert steps[0].key == "marketplace.connect_api"
assert steps[0].order == 200
assert steps[1].key == "marketplace.import_products"
assert steps[1].order == 210
def test_connect_api_incomplete_without_credentials(self, db, mp_store):
"""connect_api step is incomplete when no credentials exist."""
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.connect_api"
)
assert result is False
def test_connect_api_complete_with_credentials(self, db, mp_store):
"""connect_api step is complete when StoreLetzshopCredentials has api_key."""
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
creds = StoreLetzshopCredentials(
store_id=mp_store.id,
api_key_encrypted="encrypted_key_here",
)
db.add(creds)
db.commit()
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.connect_api"
)
assert result is True
def test_import_products_incomplete_without_products(self, db, mp_store):
"""import_products step is incomplete when store has no products."""
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.import_products"
)
assert result is False
def test_import_products_complete_with_products(self, db, mp_store):
"""import_products step is complete when store has at least one product."""
from app.modules.catalog.models.product import Product
product = Product(
store_id=mp_store.id,
store_sku=f"SKU-{uuid.uuid4().hex[:8]}",
is_active=True,
)
db.add(product)
db.commit()
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.import_products"
)
assert result is True
def test_unknown_step_key_returns_false(self, db, mp_store):
"""Returns False for unrecognized step key."""
result = self.provider.is_step_completed(
db, mp_store.id, "marketplace.unknown"
)
assert result is False

View File

@@ -36,6 +36,15 @@ def _get_feature_provider():
return tenancy_feature_provider return tenancy_feature_provider
def _get_onboarding_provider():
"""Lazy import of onboarding provider to avoid circular imports."""
from app.modules.tenancy.services.tenancy_onboarding import (
tenancy_onboarding_provider,
)
return tenancy_onboarding_provider
tenancy_module = ModuleDefinition( tenancy_module = ModuleDefinition(
code="tenancy", code="tenancy",
name="Tenancy Management", name="Tenancy Management",
@@ -233,6 +242,7 @@ tenancy_module = ModuleDefinition(
# Widget provider for dashboard widgets # Widget provider for dashboard widgets
widget_provider=_get_widget_provider, widget_provider=_get_widget_provider,
feature_provider=_get_feature_provider, feature_provider=_get_feature_provider,
onboarding_provider=_get_onboarding_provider,
) )
__all__ = ["tenancy_module"] __all__ = ["tenancy_module"]

View File

@@ -0,0 +1,55 @@
# app/modules/tenancy/services/tenancy_onboarding.py
"""
Onboarding provider for the tenancy module.
Provides the "Customize your store" onboarding step that is always present
for all platforms. Completed when the store has a description or a logo.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.onboarding import OnboardingStepDefinition
logger = logging.getLogger(__name__)
class TenancyOnboardingProvider:
"""Onboarding provider for tenancy module (always present)."""
@property
def onboarding_category(self) -> str:
return "tenancy"
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
return [
OnboardingStepDefinition(
key="tenancy.customize_store",
title_key="onboarding.tenancy.customize_store.title",
description_key="onboarding.tenancy.customize_store.description",
icon="settings",
route_template="/store/{store_code}/settings",
order=100,
category="tenancy",
),
]
def is_step_completed(
self, db: Session, store_id: int, step_key: str
) -> bool:
if step_key != "tenancy.customize_store":
return False
from app.modules.tenancy.models.store import Store
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
return False
has_description = bool(store.description and store.description.strip())
has_logo = bool(store.get_logo_url())
return has_description or has_logo
tenancy_onboarding_provider = TenancyOnboardingProvider()

View File

@@ -0,0 +1,115 @@
# app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
"""Unit tests for TenancyOnboardingProvider."""
import uuid
import pytest
from app.modules.tenancy.models import Merchant, Store, User
from app.modules.tenancy.services.tenancy_onboarding import TenancyOnboardingProvider
@pytest.fixture
def onb_owner(db):
"""Create a store owner for onboarding tests."""
from middleware.auth import AuthManager
auth = AuthManager()
user = User(
email=f"onbowner_{uuid.uuid4().hex[:8]}@test.com",
username=f"onbowner_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def onb_merchant(db, onb_owner):
"""Create a merchant for onboarding tests."""
merchant = Merchant(
name="Onboarding Test Merchant",
owner_user_id=onb_owner.id,
contact_email=onb_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def onb_store(db, onb_merchant):
"""Create a store with no description and no logo."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=onb_merchant.id,
store_code=f"ONBSTORE_{uid.upper()}",
subdomain=f"onbstore{uid.lower()}",
name="Onboarding Test Store",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.mark.unit
@pytest.mark.tenancy
class TestTenancyOnboardingProvider:
"""Tests for TenancyOnboardingProvider."""
def setup_method(self):
self.provider = TenancyOnboardingProvider()
def test_category(self):
"""Returns 'tenancy' as the onboarding category."""
assert self.provider.onboarding_category == "tenancy"
def test_get_onboarding_steps_returns_one_step(self):
"""Returns exactly one step: customize_store."""
steps = self.provider.get_onboarding_steps()
assert len(steps) == 1
assert steps[0].key == "tenancy.customize_store"
assert steps[0].route_template == "/store/{store_code}/settings"
assert steps[0].order == 100
def test_incomplete_when_no_description_no_logo(self, db, onb_store):
"""Step is not completed when store has no description and no logo."""
assert onb_store.description is None or onb_store.description.strip() == ""
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
assert result is False
def test_completed_when_store_has_description(self, db, onb_store):
"""Step is completed when store has a non-empty description."""
onb_store.description = "We sell great coffee!"
db.commit()
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
assert result is True
def test_incomplete_when_description_is_whitespace(self, db, onb_store):
"""Step is not completed when description is whitespace only."""
onb_store.description = " "
db.commit()
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
assert result is False
def test_incomplete_for_nonexistent_store(self, db):
"""Returns False for a store ID that doesn't exist."""
result = self.provider.is_step_completed(db, 999999, "tenancy.customize_store")
assert result is False
def test_incomplete_for_unknown_step_key(self, db, onb_store):
"""Returns False for an unrecognized step key."""
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.unknown_step")
assert result is False

View File

@@ -337,6 +337,85 @@
{% endmacro %} {% endmacro %}
{# =============================================================================
Onboarding Banner
Shows a checklist of post-signup onboarding steps from all enabled modules.
Steps are fetched from the API, dismiss is session-scoped (sessionStorage).
Usage:
{{ onboarding_banner() }}
============================================================================= #}
{% macro onboarding_banner() %}
<div x-data="onboardingBanner()"
x-show="visible"
x-cloak
class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
{# Header with progress #}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('rocket', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="t('onboarding.banner.title')">Get Started</h3>
<span class="text-sm text-gray-500 dark:text-gray-400"
x-text="`${completedSteps}/${totalSteps}`"></span>
</div>
<button @click="dismiss()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
:title="t('onboarding.banner.dismiss')">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{# Progress bar #}
<div class="mt-3 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-purple-600 h-2 rounded-full transition-all duration-500"
:style="`width: ${progressPercentage}%`"></div>
</div>
</div>
{# Step checklist #}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="step in steps" :key="step.key">
<a :href="step.route"
class="flex items-center px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
{# Completion indicator #}
<div class="flex-shrink-0 mr-4">
<template x-if="step.completed">
<div class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
</template>
<template x-if="!step.completed">
<div class="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600 group-hover:border-purple-400 transition-colors"></div>
</template>
</div>
{# Step icon + text #}
<div class="flex-shrink-0 mr-3">
<span x-html="$icon(step.icon, 'w-5 h-5 text-gray-400 dark:text-gray-500')" class="block"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white"
:class="{ 'line-through text-gray-400 dark:text-gray-500': step.completed }"
x-text="t(step.title_key)"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"
x-text="t(step.description_key)"></p>
</div>
{# Arrow #}
<template x-if="!step.completed">
<svg class="w-4 h-4 text-gray-400 group-hover:text-purple-500 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</template>
</a>
</template>
</div>
</div>
{% endmacro %}
{# ============================================================================= {# =============================================================================
Email Settings Warning Email Settings Warning
Shows warning banner when store email settings are not configured. Shows warning banner when store email settings are not configured.

File diff suppressed because it is too large Load Diff