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>
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
•
|
|
||||||
{% 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 %}
|
|
||||||
@@ -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 }}€/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 }}€/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 {
|
||||||
|
|||||||
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
364
app/modules/billing/tests/unit/test_signup_service.py
Normal 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"
|
||||||
@@ -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",
|
||||||
|
|||||||
154
app/modules/contracts/onboarding.py
Normal file
154
app/modules/contracts/onboarding.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
157
app/modules/core/services/onboarding_aggregator.py
Normal file
157
app/modules/core/services/onboarding_aggregator.py
Normal 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"]
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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") }}
|
||||||
|
|||||||
215
app/modules/core/tests/integration/test_onboarding_routes.py
Normal file
215
app/modules/core/tests/integration/test_onboarding_routes.py
Normal 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
|
||||||
275
app/modules/core/tests/unit/test_onboarding_aggregator.py
Normal file
275
app/modules/core/tests/unit/test_onboarding_aggregator.py
Normal 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"
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
61
app/modules/loyalty/services/loyalty_onboarding.py
Normal file
61
app/modules/loyalty/services/loyalty_onboarding.py
Normal 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()
|
||||||
125
app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
Normal file
125
app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
Normal 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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
79
app/modules/marketplace/services/marketplace_onboarding.py
Normal file
79
app/modules/marketplace/services/marketplace_onboarding.py
Normal 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()
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
|||||||
55
app/modules/tenancy/services/tenancy_onboarding.py
Normal file
55
app/modules/tenancy/services/tenancy_onboarding.py
Normal 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()
|
||||||
115
app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
Normal file
115
app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user