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
|
||||
is_annual: bool = False
|
||||
platform_code: str
|
||||
language: str = "fr"
|
||||
|
||||
|
||||
class SignupStartResponse(BaseModel):
|
||||
@@ -64,12 +65,14 @@ class CreateAccountRequest(BaseModel):
|
||||
|
||||
|
||||
class CreateAccountResponse(BaseModel):
|
||||
"""Response from account creation."""
|
||||
"""Response from account creation (includes auto-created store)."""
|
||||
|
||||
session_id: str
|
||||
user_id: int
|
||||
merchant_id: int
|
||||
stripe_customer_id: str
|
||||
store_id: int
|
||||
store_code: str
|
||||
|
||||
|
||||
class CreateStoreRequest(BaseModel):
|
||||
@@ -137,6 +140,7 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
||||
tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
platform_code=request.platform_code,
|
||||
language=request.language,
|
||||
)
|
||||
|
||||
return SignupStartResponse(
|
||||
@@ -175,6 +179,8 @@ async def create_account(
|
||||
user_id=result.user_id,
|
||||
merchant_id=result.merchant_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.features import FeatureProviderProtocol
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||
from app.modules.contracts.onboarding import OnboardingProviderProtocol
|
||||
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
||||
|
||||
from app.modules.enums import FrontendType
|
||||
@@ -486,6 +487,29 @@ class ModuleDefinition:
|
||||
# to report where media is being used.
|
||||
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)
|
||||
# =========================================================================
|
||||
@@ -955,6 +979,24 @@ class ModuleDefinition:
|
||||
return None
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
@@ -114,14 +114,8 @@ async def signup_page(
|
||||
context["is_annual"] = annual
|
||||
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(
|
||||
template_name,
|
||||
"billing/platform/signup.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,11 +84,13 @@ class SignupSessionData:
|
||||
|
||||
@dataclass
|
||||
class AccountCreationResult:
|
||||
"""Result of account creation."""
|
||||
"""Result of account creation (includes auto-created store)."""
|
||||
|
||||
user_id: int
|
||||
merchant_id: int
|
||||
stripe_customer_id: str
|
||||
store_id: int
|
||||
store_code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -131,6 +133,7 @@ class SignupService:
|
||||
tier_code: str,
|
||||
is_annual: bool,
|
||||
platform_code: str,
|
||||
language: str = "fr",
|
||||
) -> str:
|
||||
"""
|
||||
Create a new signup session.
|
||||
@@ -139,6 +142,7 @@ class SignupService:
|
||||
tier_code: The subscription tier code
|
||||
is_annual: Whether annual billing is selected
|
||||
platform_code: Platform code (e.g., 'loyalty', 'oms')
|
||||
language: User's browsing language (from lang cookie)
|
||||
|
||||
Returns:
|
||||
The session ID
|
||||
@@ -171,6 +175,7 @@ class SignupService:
|
||||
"tier_code": tier.value,
|
||||
"is_annual": is_annual,
|
||||
"platform_code": platform_code,
|
||||
"language": language,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
@@ -304,10 +309,10 @@ class SignupService:
|
||||
phone: str | None = None,
|
||||
) -> 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
|
||||
separate step (create_store) so each platform can customize it.
|
||||
Creates User + Merchant + Store + Stripe Customer + MerchantSubscription.
|
||||
Store name defaults to merchant_name, language from signup session.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -320,7 +325,7 @@ class SignupService:
|
||||
phone: Optional phone number
|
||||
|
||||
Returns:
|
||||
AccountCreationResult with user and merchant IDs
|
||||
AccountCreationResult with user, merchant, and store IDs
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If session not found
|
||||
@@ -338,7 +343,7 @@ class SignupService:
|
||||
username = self.generate_unique_username(db, email)
|
||||
|
||||
# Create User
|
||||
from app.modules.tenancy.models import Merchant, User
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
@@ -362,8 +367,7 @@ class SignupService:
|
||||
db.add(merchant)
|
||||
db.flush()
|
||||
|
||||
# Create Stripe Customer (linked to merchant, not store)
|
||||
# We use a temporary store-like object for Stripe metadata
|
||||
# Create Stripe Customer
|
||||
stripe_customer_id = stripe_service.create_customer_for_merchant(
|
||||
merchant=merchant,
|
||||
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
|
||||
self.update_session(session_id, {
|
||||
@@ -384,18 +419,24 @@ class SignupService:
|
||||
"merchant_name": merchant_name,
|
||||
"email": email,
|
||||
"stripe_customer_id": stripe_customer_id,
|
||||
"store_id": store.id,
|
||||
"store_code": store_code,
|
||||
"platform_id": platform_id,
|
||||
"step": "account_created",
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"Created account for {email}: "
|
||||
f"user_id={user.id}, merchant_id={merchant.id}"
|
||||
f"Created account + store for {email}: "
|
||||
f"user_id={user.id}, merchant_id={merchant.id}, "
|
||||
f"store_code={store_code}"
|
||||
)
|
||||
|
||||
return AccountCreationResult(
|
||||
user_id=user.id,
|
||||
merchant_id=merchant.id,
|
||||
stripe_customer_id=stripe_customer_id,
|
||||
store_id=store.id,
|
||||
store_code=store_code,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -512,7 +553,7 @@ class SignupService:
|
||||
|
||||
Raises:
|
||||
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)
|
||||
|
||||
@@ -523,12 +564,6 @@ class SignupService:
|
||||
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
|
||||
setup_intent = stripe_service.create_setup_intent(
|
||||
customer_id=stripe_customer_id,
|
||||
@@ -775,21 +810,11 @@ class SignupService:
|
||||
self, db: Session, session: dict, store_code: str
|
||||
) -> str:
|
||||
"""
|
||||
Determine redirect URL after signup based on platform.
|
||||
Determine redirect URL after signup.
|
||||
|
||||
Marketplace platforms → onboarding wizard.
|
||||
Other platforms (loyalty, etc.) → dashboard.
|
||||
Always redirects to the store dashboard. Platform-specific onboarding
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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 #}
|
||||
{# Multi-step Signup Wizard #}
|
||||
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
||||
{% extends "platform/base.html" %}
|
||||
|
||||
{% block title %}Start Your Free Trial - Orion{% endblock %}
|
||||
@@ -16,8 +16,8 @@
|
||||
{# Progress Steps #}
|
||||
<div class="mb-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
||||
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Payment']" :key="index">
|
||||
<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"
|
||||
: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">
|
||||
@@ -32,7 +32,7 @@
|
||||
<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">
|
||||
<template x-if="index < 2">
|
||||
<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>
|
||||
@@ -87,10 +87,10 @@
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -116,52 +116,9 @@
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
||||
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">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>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
<span class="text-red-500">*</span> Required fields
|
||||
@@ -187,7 +144,7 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<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"/>
|
||||
@@ -218,7 +175,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Back
|
||||
</button>
|
||||
@@ -231,9 +188,9 @@
|
||||
</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>
|
||||
<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 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">
|
||||
Back
|
||||
</button>
|
||||
@@ -276,12 +233,7 @@ function signupWizard() {
|
||||
selectedTier: '{{ selected_tier or "professional" }}',
|
||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||
|
||||
// Step 2: Letzshop
|
||||
letzshopUrl: '',
|
||||
letzshopStore: null,
|
||||
letzshopError: null,
|
||||
|
||||
// Step 3: Account
|
||||
// Step 2: Account
|
||||
account: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
@@ -291,7 +243,7 @@ function signupWizard() {
|
||||
},
|
||||
accountError: null,
|
||||
|
||||
// Step 4: Payment
|
||||
// Step 3: Payment
|
||||
stripe: null,
|
||||
cardElement: null,
|
||||
paymentProcessing: false,
|
||||
@@ -306,13 +258,10 @@ function signupWizard() {
|
||||
if (params.get('annual') === '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) => {
|
||||
if (step === 4) {
|
||||
if (step === 3) {
|
||||
this.initStripe();
|
||||
}
|
||||
});
|
||||
@@ -327,7 +276,8 @@ function signupWizard() {
|
||||
body: JSON.stringify({
|
||||
tier_code: this.selectedTier,
|
||||
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() {
|
||||
return this.account.firstName.trim() &&
|
||||
this.account.lastName.trim() &&
|
||||
@@ -427,7 +324,7 @@ function signupWizard() {
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.currentStep = 4;
|
||||
this.currentStep = 3;
|
||||
} else {
|
||||
this.accountError = data.detail || 'Failed to create account';
|
||||
}
|
||||
@@ -516,7 +413,6 @@ function signupWizard() {
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('store_token', data.access_token);
|
||||
localStorage.setItem('storeCode', data.store_code);
|
||||
console.log('Store token stored for automatic login');
|
||||
}
|
||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
||||
} 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,
|
||||
MetricValue,
|
||||
)
|
||||
from app.modules.contracts.onboarding import (
|
||||
OnboardingProviderProtocol,
|
||||
OnboardingStepDefinition,
|
||||
OnboardingStepStatus,
|
||||
)
|
||||
from app.modules.contracts.widgets import (
|
||||
BreakdownWidget,
|
||||
DashboardWidget,
|
||||
@@ -92,6 +97,10 @@ __all__ = [
|
||||
"MetricValue",
|
||||
"MetricsContext",
|
||||
"MetricsProviderProtocol",
|
||||
# Onboarding protocols
|
||||
"OnboardingStepDefinition",
|
||||
"OnboardingStepStatus",
|
||||
"OnboardingProviderProtocol",
|
||||
# Widget protocols
|
||||
"WidgetContext",
|
||||
"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,
|
||||
StoreRevenueStats,
|
||||
)
|
||||
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
|
||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||
from app.modules.tenancy.exceptions import StoreNotActiveException
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
@@ -124,3 +125,27 @@ def get_store_dashboard_stats(
|
||||
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('[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() {
|
||||
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
|
||||
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
|
||||
{% 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 %}
|
||||
<!-- Email Settings Warning -->
|
||||
{{ email_settings_warning() }}
|
||||
|
||||
<!-- Onboarding Banner -->
|
||||
{{ onboarding_banner() }}
|
||||
|
||||
<!-- Limit Warnings -->
|
||||
{{ limit_warning("orders") }}
|
||||
{{ 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
|
||||
|
||||
|
||||
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 = ModuleDefinition(
|
||||
code="loyalty",
|
||||
@@ -260,6 +269,8 @@ loyalty_module = ModuleDefinition(
|
||||
],
|
||||
# Feature provider for billing feature gating
|
||||
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
|
||||
|
||||
|
||||
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 = ModuleDefinition(
|
||||
code="marketplace",
|
||||
@@ -186,6 +195,8 @@ marketplace_module = ModuleDefinition(
|
||||
widget_provider=_get_widget_provider,
|
||||
# Feature provider for feature flags
|
||||
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.
|
||||
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(
|
||||
"marketplace/store/marketplace.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
@@ -127,15 +119,7 @@ async def store_letzshop_page(
|
||||
"""
|
||||
Render Letzshop integration page.
|
||||
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(
|
||||
"marketplace/store/letzshop.html",
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
code="tenancy",
|
||||
name="Tenancy Management",
|
||||
@@ -233,6 +242,7 @@ tenancy_module = ModuleDefinition(
|
||||
# Widget provider for dashboard widgets
|
||||
widget_provider=_get_widget_provider,
|
||||
feature_provider=_get_feature_provider,
|
||||
onboarding_provider=_get_onboarding_provider,
|
||||
)
|
||||
|
||||
__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 %}
|
||||
|
||||
|
||||
{# =============================================================================
|
||||
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
|
||||
Shows warning banner when store email settings are not configured.
|
||||
|
||||
Reference in New Issue
Block a user