Compare commits
2 Commits
f8a2394da5
...
adbecd360b
| Author | SHA1 | Date | |
|---|---|---|---|
| adbecd360b | |||
| ef9ea29643 |
@@ -40,6 +40,7 @@ class SignupStartRequest(BaseModel):
|
|||||||
tier_code: str
|
tier_code: str
|
||||||
is_annual: bool = False
|
is_annual: bool = False
|
||||||
platform_code: str
|
platform_code: str
|
||||||
|
language: str = "fr"
|
||||||
|
|
||||||
|
|
||||||
class SignupStartResponse(BaseModel):
|
class SignupStartResponse(BaseModel):
|
||||||
@@ -64,12 +65,14 @@ class CreateAccountRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CreateAccountResponse(BaseModel):
|
class CreateAccountResponse(BaseModel):
|
||||||
"""Response from account creation."""
|
"""Response from account creation (includes auto-created store)."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
user_id: int
|
user_id: int
|
||||||
merchant_id: int
|
merchant_id: int
|
||||||
stripe_customer_id: str
|
stripe_customer_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
class CreateStoreRequest(BaseModel):
|
class CreateStoreRequest(BaseModel):
|
||||||
@@ -137,6 +140,7 @@ async def start_signup(request: SignupStartRequest) -> SignupStartResponse:
|
|||||||
tier_code=request.tier_code,
|
tier_code=request.tier_code,
|
||||||
is_annual=request.is_annual,
|
is_annual=request.is_annual,
|
||||||
platform_code=request.platform_code,
|
platform_code=request.platform_code,
|
||||||
|
language=request.language,
|
||||||
)
|
)
|
||||||
|
|
||||||
return SignupStartResponse(
|
return SignupStartResponse(
|
||||||
@@ -175,6 +179,8 @@ async def create_account(
|
|||||||
user_id=result.user_id,
|
user_id=result.user_id,
|
||||||
merchant_id=result.merchant_id,
|
merchant_id=result.merchant_id,
|
||||||
stripe_customer_id=result.stripe_customer_id,
|
stripe_customer_id=result.stripe_customer_id,
|
||||||
|
store_id=result.store_id,
|
||||||
|
store_code=result.store_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
|
|||||||
from app.modules.contracts.cms import MediaUsageProviderProtocol
|
from app.modules.contracts.cms import MediaUsageProviderProtocol
|
||||||
from app.modules.contracts.features import FeatureProviderProtocol
|
from app.modules.contracts.features import FeatureProviderProtocol
|
||||||
from app.modules.contracts.metrics import MetricsProviderProtocol
|
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||||
|
from app.modules.contracts.onboarding import OnboardingProviderProtocol
|
||||||
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
||||||
|
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
@@ -486,6 +487,29 @@ class ModuleDefinition:
|
|||||||
# to report where media is being used.
|
# to report where media is being used.
|
||||||
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
|
media_usage_provider: "Callable[[], MediaUsageProviderProtocol] | None" = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Onboarding Provider (Module-Driven Post-Signup Onboarding)
|
||||||
|
# =========================================================================
|
||||||
|
# Callable that returns an OnboardingProviderProtocol implementation.
|
||||||
|
# Modules declare onboarding steps (what needs to be configured after signup)
|
||||||
|
# and provide completion checks. The core module's OnboardingAggregator
|
||||||
|
# discovers and aggregates all providers into a dashboard checklist banner.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# def _get_onboarding_provider():
|
||||||
|
# from app.modules.marketplace.services.marketplace_onboarding import (
|
||||||
|
# marketplace_onboarding_provider,
|
||||||
|
# )
|
||||||
|
# return marketplace_onboarding_provider
|
||||||
|
#
|
||||||
|
# marketplace_module = ModuleDefinition(
|
||||||
|
# code="marketplace",
|
||||||
|
# onboarding_provider=_get_onboarding_provider,
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# The provider will be discovered by core's OnboardingAggregator service.
|
||||||
|
onboarding_provider: "Callable[[], OnboardingProviderProtocol] | None" = None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -955,6 +979,24 @@ class ModuleDefinition:
|
|||||||
return None
|
return None
|
||||||
return self.media_usage_provider()
|
return self.media_usage_provider()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Onboarding Provider Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def has_onboarding_provider(self) -> bool:
|
||||||
|
"""Check if this module has an onboarding provider."""
|
||||||
|
return self.onboarding_provider is not None
|
||||||
|
|
||||||
|
def get_onboarding_provider_instance(self) -> "OnboardingProviderProtocol | None":
|
||||||
|
"""Get the onboarding provider instance for this module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OnboardingProviderProtocol instance, or None
|
||||||
|
"""
|
||||||
|
if self.onboarding_provider is None:
|
||||||
|
return None
|
||||||
|
return self.onboarding_provider()
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Magic Methods
|
# Magic Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -114,14 +114,8 @@ async def signup_page(
|
|||||||
context["is_annual"] = annual
|
context["is_annual"] = annual
|
||||||
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
context["tiers"] = _get_tiers_data(db, platform_id=platform.id)
|
||||||
|
|
||||||
# Route to platform-specific signup template
|
|
||||||
if platform.code == "loyalty":
|
|
||||||
template_name = "billing/platform/signup-loyalty.html"
|
|
||||||
else:
|
|
||||||
template_name = "billing/platform/signup.html"
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
template_name,
|
"billing/platform/signup.html",
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ class SignupSessionData:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AccountCreationResult:
|
class AccountCreationResult:
|
||||||
"""Result of account creation."""
|
"""Result of account creation (includes auto-created store)."""
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
merchant_id: int
|
merchant_id: int
|
||||||
stripe_customer_id: str
|
stripe_customer_id: str
|
||||||
|
store_id: int
|
||||||
|
store_code: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -131,6 +133,7 @@ class SignupService:
|
|||||||
tier_code: str,
|
tier_code: str,
|
||||||
is_annual: bool,
|
is_annual: bool,
|
||||||
platform_code: str,
|
platform_code: str,
|
||||||
|
language: str = "fr",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create a new signup session.
|
Create a new signup session.
|
||||||
@@ -139,6 +142,7 @@ class SignupService:
|
|||||||
tier_code: The subscription tier code
|
tier_code: The subscription tier code
|
||||||
is_annual: Whether annual billing is selected
|
is_annual: Whether annual billing is selected
|
||||||
platform_code: Platform code (e.g., 'loyalty', 'oms')
|
platform_code: Platform code (e.g., 'loyalty', 'oms')
|
||||||
|
language: User's browsing language (from lang cookie)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The session ID
|
The session ID
|
||||||
@@ -171,6 +175,7 @@ class SignupService:
|
|||||||
"tier_code": tier.value,
|
"tier_code": tier.value,
|
||||||
"is_annual": is_annual,
|
"is_annual": is_annual,
|
||||||
"platform_code": platform_code,
|
"platform_code": platform_code,
|
||||||
|
"language": language,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
@@ -304,10 +309,10 @@ class SignupService:
|
|||||||
phone: str | None = None,
|
phone: str | None = None,
|
||||||
) -> AccountCreationResult:
|
) -> AccountCreationResult:
|
||||||
"""
|
"""
|
||||||
Create user and merchant accounts.
|
Create user, merchant, store, and subscription in a single atomic step.
|
||||||
|
|
||||||
Creates User + Merchant + Stripe Customer. Store creation is a
|
Creates User + Merchant + Store + Stripe Customer + MerchantSubscription.
|
||||||
separate step (create_store) so each platform can customize it.
|
Store name defaults to merchant_name, language from signup session.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -320,7 +325,7 @@ class SignupService:
|
|||||||
phone: Optional phone number
|
phone: Optional phone number
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AccountCreationResult with user and merchant IDs
|
AccountCreationResult with user, merchant, and store IDs
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundException: If session not found
|
ResourceNotFoundException: If session not found
|
||||||
@@ -338,7 +343,7 @@ class SignupService:
|
|||||||
username = self.generate_unique_username(db, email)
|
username = self.generate_unique_username(db, email)
|
||||||
|
|
||||||
# Create User
|
# Create User
|
||||||
from app.modules.tenancy.models import Merchant, User
|
from app.modules.tenancy.models import Merchant, Store, User
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
email=email,
|
email=email,
|
||||||
@@ -362,8 +367,7 @@ class SignupService:
|
|||||||
db.add(merchant)
|
db.add(merchant)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Create Stripe Customer (linked to merchant, not store)
|
# Create Stripe Customer
|
||||||
# We use a temporary store-like object for Stripe metadata
|
|
||||||
stripe_customer_id = stripe_service.create_customer_for_merchant(
|
stripe_customer_id = stripe_service.create_customer_for_merchant(
|
||||||
merchant=merchant,
|
merchant=merchant,
|
||||||
email=email,
|
email=email,
|
||||||
@@ -375,7 +379,38 @@ class SignupService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit() # SVC-006 - Atomic account creation needs commit
|
# Create Store (name = merchant_name, language from browsing session)
|
||||||
|
store_code = self.generate_unique_store_code(db, merchant_name)
|
||||||
|
subdomain = self.generate_unique_subdomain(db, merchant_name)
|
||||||
|
language = session.get("language", "fr")
|
||||||
|
|
||||||
|
store = Store(
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
store_code=store_code,
|
||||||
|
subdomain=subdomain,
|
||||||
|
name=merchant_name,
|
||||||
|
contact_email=email,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
if language:
|
||||||
|
store.default_language = language
|
||||||
|
db.add(store)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Resolve platform and create subscription
|
||||||
|
platform_id = self._resolve_platform_id(db, session)
|
||||||
|
|
||||||
|
subscription = sub_service.create_merchant_subscription(
|
||||||
|
db=db,
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
platform_id=platform_id,
|
||||||
|
tier_code=session.get("tier_code", "essential"),
|
||||||
|
trial_days=settings.stripe_trial_days,
|
||||||
|
is_annual=session.get("is_annual", False),
|
||||||
|
)
|
||||||
|
subscription.stripe_customer_id = stripe_customer_id
|
||||||
|
|
||||||
|
db.commit() # SVC-006 - Atomic account + store creation
|
||||||
|
|
||||||
# Update session
|
# Update session
|
||||||
self.update_session(session_id, {
|
self.update_session(session_id, {
|
||||||
@@ -384,18 +419,24 @@ class SignupService:
|
|||||||
"merchant_name": merchant_name,
|
"merchant_name": merchant_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
"stripe_customer_id": stripe_customer_id,
|
"stripe_customer_id": stripe_customer_id,
|
||||||
|
"store_id": store.id,
|
||||||
|
"store_code": store_code,
|
||||||
|
"platform_id": platform_id,
|
||||||
"step": "account_created",
|
"step": "account_created",
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created account for {email}: "
|
f"Created account + store for {email}: "
|
||||||
f"user_id={user.id}, merchant_id={merchant.id}"
|
f"user_id={user.id}, merchant_id={merchant.id}, "
|
||||||
|
f"store_code={store_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return AccountCreationResult(
|
return AccountCreationResult(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
merchant_id=merchant.id,
|
merchant_id=merchant.id,
|
||||||
stripe_customer_id=stripe_customer_id,
|
stripe_customer_id=stripe_customer_id,
|
||||||
|
store_id=store.id,
|
||||||
|
store_code=store_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -512,7 +553,7 @@ class SignupService:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundException: If session not found
|
ResourceNotFoundException: If session not found
|
||||||
ValidationException: If store not created yet
|
ValidationException: If account not created yet
|
||||||
"""
|
"""
|
||||||
session = self.get_session_or_raise(session_id)
|
session = self.get_session_or_raise(session_id)
|
||||||
|
|
||||||
@@ -523,12 +564,6 @@ class SignupService:
|
|||||||
field="session_id",
|
field="session_id",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not session.get("store_id"):
|
|
||||||
raise ValidationException(
|
|
||||||
message="Store not created. Please complete the store step first.",
|
|
||||||
field="session_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create SetupIntent
|
# Create SetupIntent
|
||||||
setup_intent = stripe_service.create_setup_intent(
|
setup_intent = stripe_service.create_setup_intent(
|
||||||
customer_id=stripe_customer_id,
|
customer_id=stripe_customer_id,
|
||||||
@@ -775,21 +810,11 @@ class SignupService:
|
|||||||
self, db: Session, session: dict, store_code: str
|
self, db: Session, session: dict, store_code: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Determine redirect URL after signup based on platform.
|
Determine redirect URL after signup.
|
||||||
|
|
||||||
Marketplace platforms → onboarding wizard.
|
Always redirects to the store dashboard. Platform-specific onboarding
|
||||||
Other platforms (loyalty, etc.) → dashboard.
|
is handled by the dashboard's onboarding banner (module-driven).
|
||||||
"""
|
"""
|
||||||
from app.modules.service import module_service
|
|
||||||
|
|
||||||
platform_id = session.get("platform_id")
|
|
||||||
if platform_id:
|
|
||||||
try:
|
|
||||||
if module_service.is_module_enabled(db, platform_id, "marketplace"):
|
|
||||||
return f"/store/{store_code}/onboarding"
|
|
||||||
except Exception:
|
|
||||||
pass # If check fails, default to dashboard
|
|
||||||
|
|
||||||
return f"/store/{store_code}/dashboard"
|
return f"/store/{store_code}/dashboard"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,520 +0,0 @@
|
|||||||
{# app/templates/platform/signup-loyalty.html #}
|
|
||||||
{# Loyalty Platform Signup Wizard — 4 steps: Plan → Account → Store → Payment #}
|
|
||||||
{% extends "platform/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Start Your Free Trial - RewardFlow{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
{# Stripe.js for payment #}
|
|
||||||
<script defer src="https://js.stripe.com/v3/"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="loyaltySignupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
|
|
||||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
|
|
||||||
{# Progress Steps #}
|
|
||||||
<div class="mb-12">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Set Up Store', 'Payment']" :key="index">
|
|
||||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
|
||||||
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
|
||||||
<template x-if="currentStep > index + 1">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template x-if="currentStep <= index + 1">
|
|
||||||
<span x-text="index + 1"></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
|
||||||
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
|
||||||
x-text="stepName"></span>
|
|
||||||
<template x-if="index < 3">
|
|
||||||
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
|
||||||
<div class="h-full bg-indigo-600 rounded transition-all"
|
|
||||||
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Form Card #}
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
||||||
|
|
||||||
{# ===============================================================
|
|
||||||
STEP 1: SELECT PLAN
|
|
||||||
=============================================================== #}
|
|
||||||
<div x-show="currentStep === 1" class="p-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
|
|
||||||
|
|
||||||
{# Billing Toggle #}
|
|
||||||
<div class="flex items-center justify-center mb-8 space-x-4">
|
|
||||||
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
|
|
||||||
<button @click="isAnnual = !isAnnual"
|
|
||||||
class="relative w-12 h-6 rounded-full transition-colors"
|
|
||||||
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
|
||||||
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
|
|
||||||
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
|
||||||
</button>
|
|
||||||
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
|
||||||
Annual <span class="text-green-600 text-xs">Save 17%</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Tier Options #}
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% for tier in tiers %}
|
|
||||||
{% if not tier.is_enterprise %}
|
|
||||||
<label class="block">
|
|
||||||
<input type="radio" name="tier" value="{{ tier.code }}"
|
|
||||||
x-model="selectedTier" class="hidden peer"/>
|
|
||||||
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
|
|
||||||
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
|
|
||||||
border-gray-200 dark:border-gray-700 hover:border-gray-300">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
{% if tier.products_limit %}{{ tier.products_limit }} loyalty programs{% else %}Unlimited{% endif %}
|
|
||||||
•
|
|
||||||
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<template x-if="!isAnnual">
|
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
|
|
||||||
</template>
|
|
||||||
<template x-if="isAnnual">
|
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Free Trial Note #}
|
|
||||||
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
|
||||||
<p class="text-sm text-green-800 dark:text-green-300">
|
|
||||||
<strong>{{ trial_days }}-day free trial.</strong>
|
|
||||||
We'll collect your payment info, but you won't be charged until the trial ends.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button @click="startSignup()"
|
|
||||||
:disabled="!selectedTier || loading"
|
|
||||||
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ===============================================================
|
|
||||||
STEP 2: CREATE ACCOUNT
|
|
||||||
=============================================================== #}
|
|
||||||
<div x-show="currentStep === 2" class="p-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
|
||||||
<span class="text-red-500">*</span> Required fields
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
First Name <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" x-model="account.firstName" required
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Last Name <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" x-model="account.lastName" required
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Business Name <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" x-model="account.merchantName" required
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Email <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="email" x-model="account.email" required
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Password <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="password" x-model="account.password" required minlength="8"
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template x-if="accountError">
|
|
||||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
|
||||||
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
|
||||||
<button @click="currentStep = 1"
|
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button @click="createAccount()"
|
|
||||||
:disabled="loading || !isAccountValid()"
|
|
||||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ===============================================================
|
|
||||||
STEP 3: SET UP STORE
|
|
||||||
=============================================================== #}
|
|
||||||
<div x-show="currentStep === 3" class="p-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Set Up Your Store</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">Your store is where your loyalty programs live. You can change these settings later.</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Store Name
|
|
||||||
</label>
|
|
||||||
<input type="text" x-model="storeName"
|
|
||||||
:placeholder="account.merchantName || 'My Store'"
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Defaults to your business name if left empty</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Store Language
|
|
||||||
</label>
|
|
||||||
<select x-model="storeLanguage"
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
|
|
||||||
<option value="fr">Français</option>
|
|
||||||
<option value="de">Deutsch</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="lb">Lëtzebuergesch</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template x-if="storeError">
|
|
||||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
|
||||||
<p class="text-red-800 dark:text-red-300" x-text="storeError"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
|
||||||
<button @click="currentStep = 2"
|
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button @click="createStore()"
|
|
||||||
:disabled="loading"
|
|
||||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
|
||||||
Continue to Payment
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ===============================================================
|
|
||||||
STEP 4: PAYMENT
|
|
||||||
=============================================================== #}
|
|
||||||
<div x-show="currentStep === 4" class="p-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
|
||||||
|
|
||||||
{# Stripe Card Element #}
|
|
||||||
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
|
||||||
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
|
||||||
<button @click="currentStep = 3"
|
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button @click="submitPayment()"
|
|
||||||
:disabled="loading || paymentProcessing"
|
|
||||||
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
|
||||||
<template x-if="paymentProcessing">
|
|
||||||
<span>Processing...</span>
|
|
||||||
</template>
|
|
||||||
<template x-if="!paymentProcessing">
|
|
||||||
<span>Start Free Trial</span>
|
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_scripts %}
|
|
||||||
<script>
|
|
||||||
function loyaltySignupWizard() {
|
|
||||||
return {
|
|
||||||
currentStep: 1,
|
|
||||||
loading: false,
|
|
||||||
sessionId: null,
|
|
||||||
platformCode: '{{ platform.code if platform else "loyalty" }}',
|
|
||||||
|
|
||||||
// Step 1: Plan
|
|
||||||
selectedTier: '{{ selected_tier or "professional" }}',
|
|
||||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
|
||||||
|
|
||||||
// Step 2: Account
|
|
||||||
account: {
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
merchantName: '',
|
|
||||||
email: '',
|
|
||||||
password: ''
|
|
||||||
},
|
|
||||||
accountError: null,
|
|
||||||
|
|
||||||
// Step 3: Store
|
|
||||||
storeName: '',
|
|
||||||
storeLanguage: 'fr',
|
|
||||||
storeError: null,
|
|
||||||
|
|
||||||
// Step 4: Payment
|
|
||||||
stripe: null,
|
|
||||||
cardElement: null,
|
|
||||||
paymentProcessing: false,
|
|
||||||
clientSecret: null,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Check URL params for pre-selection
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (params.get('tier')) {
|
|
||||||
this.selectedTier = params.get('tier');
|
|
||||||
}
|
|
||||||
if (params.get('annual') === 'true') {
|
|
||||||
this.isAnnual = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Stripe when we get to step 4
|
|
||||||
this.$watch('currentStep', (step) => {
|
|
||||||
if (step === 4) {
|
|
||||||
this.initStripe();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async startSignup() {
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/platform/signup/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
tier_code: this.selectedTier,
|
|
||||||
is_annual: this.isAnnual,
|
|
||||||
platform_code: this.platformCode
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
this.sessionId = data.session_id;
|
|
||||||
this.currentStep = 2;
|
|
||||||
} else {
|
|
||||||
alert(data.detail || 'Failed to start signup');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Failed to start signup. Please try again.');
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isAccountValid() {
|
|
||||||
return this.account.firstName.trim() &&
|
|
||||||
this.account.lastName.trim() &&
|
|
||||||
this.account.merchantName.trim() &&
|
|
||||||
this.account.email.trim() &&
|
|
||||||
this.account.password.length >= 8;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createAccount() {
|
|
||||||
this.loading = true;
|
|
||||||
this.accountError = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/platform/signup/create-account', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: this.sessionId,
|
|
||||||
email: this.account.email,
|
|
||||||
password: this.account.password,
|
|
||||||
first_name: this.account.firstName,
|
|
||||||
last_name: this.account.lastName,
|
|
||||||
merchant_name: this.account.merchantName
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
// Default store name to merchant name
|
|
||||||
if (!this.storeName) {
|
|
||||||
this.storeName = this.account.merchantName;
|
|
||||||
}
|
|
||||||
this.currentStep = 3;
|
|
||||||
} else {
|
|
||||||
this.accountError = data.detail || 'Failed to create account';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
this.accountError = 'Failed to create account. Please try again.';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async createStore() {
|
|
||||||
this.loading = true;
|
|
||||||
this.storeError = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/platform/signup/create-store', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: this.sessionId,
|
|
||||||
store_name: this.storeName || null,
|
|
||||||
language: this.storeLanguage
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
this.currentStep = 4;
|
|
||||||
} else {
|
|
||||||
this.storeError = data.detail || 'Failed to create store';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
this.storeError = 'Failed to create store. Please try again.';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async initStripe() {
|
|
||||||
{% if stripe_publishable_key %}
|
|
||||||
this.stripe = Stripe('{{ stripe_publishable_key }}');
|
|
||||||
const elements = this.stripe.elements();
|
|
||||||
|
|
||||||
this.cardElement = elements.create('card', {
|
|
||||||
style: {
|
|
||||||
base: {
|
|
||||||
fontSize: '16px',
|
|
||||||
color: '#374151',
|
|
||||||
'::placeholder': { color: '#9CA3AF' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cardElement.mount('#card-element');
|
|
||||||
this.cardElement.on('change', (event) => {
|
|
||||||
const displayError = document.getElementById('card-errors');
|
|
||||||
displayError.textContent = event.error ? event.error.message : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get SetupIntent
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/platform/signup/setup-payment', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ session_id: this.sessionId })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
this.clientSecret = data.client_secret;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting SetupIntent:', error);
|
|
||||||
}
|
|
||||||
{% else %}
|
|
||||||
console.warn('Stripe not configured');
|
|
||||||
{% endif %}
|
|
||||||
},
|
|
||||||
|
|
||||||
async submitPayment() {
|
|
||||||
if (!this.stripe || !this.clientSecret) {
|
|
||||||
alert('Payment not configured. Please contact support.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.paymentProcessing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { setupIntent, error } = await this.stripe.confirmCardSetup(
|
|
||||||
this.clientSecret,
|
|
||||||
{ payment_method: { card: this.cardElement } }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
document.getElementById('card-errors').textContent = error.message;
|
|
||||||
this.paymentProcessing = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete signup
|
|
||||||
const response = await fetch('/api/v1/platform/signup/complete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: this.sessionId,
|
|
||||||
setup_intent_id: setupIntent.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
// Store access token for automatic login
|
|
||||||
if (data.access_token) {
|
|
||||||
localStorage.setItem('store_token', data.access_token);
|
|
||||||
localStorage.setItem('storeCode', data.store_code);
|
|
||||||
}
|
|
||||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
|
||||||
} else {
|
|
||||||
alert(data.detail || 'Failed to complete signup');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Payment error:', error);
|
|
||||||
alert('Payment failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
this.paymentProcessing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{# app/templates/platform/signup.html #}
|
{# app/templates/platform/signup.html #}
|
||||||
{# Multi-step Signup Wizard #}
|
{# 3-Step Signup Wizard: Plan → Account → Payment #}
|
||||||
{% extends "platform/base.html" %}
|
{% extends "platform/base.html" %}
|
||||||
|
|
||||||
{% block title %}Start Your Free Trial - Orion{% endblock %}
|
{% block title %}Start Your Free Trial - Orion{% endblock %}
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
{# Progress Steps #}
|
{# Progress Steps #}
|
||||||
<div class="mb-12">
|
<div class="mb-12">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
<template x-for="(stepName, index) in ['Select Plan', 'Account', 'Payment']" :key="index">
|
||||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
<div class="flex items-center" :class="index < 2 ? 'flex-1' : ''">
|
||||||
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
||||||
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
||||||
<template x-if="currentStep > index + 1">
|
<template x-if="currentStep > index + 1">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
||||||
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
||||||
x-text="stepName"></span>
|
x-text="stepName"></span>
|
||||||
<template x-if="index < 3">
|
<template x-if="index < 2">
|
||||||
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
||||||
<div class="h-full bg-indigo-600 rounded transition-all"
|
<div class="h-full bg-indigo-600 rounded transition-all"
|
||||||
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
||||||
@@ -87,10 +87,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<template x-if="!isAnnual">
|
<template x-if="!isAnnual">
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="isAnnual">
|
<template x-if="isAnnual">
|
||||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
|
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,52 +116,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ===============================================================
|
{# ===============================================================
|
||||||
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
STEP 2: CREATE ACCOUNT
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 2" class="p-8">
|
<div x-show="currentStep === 2" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="letzshopUrl"
|
|
||||||
placeholder="letzshop.lu/vendors/your-shop"
|
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template x-if="letzshopStore">
|
|
||||||
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
|
||||||
<p class="text-green-800 dark:text-green-300">
|
|
||||||
Found: <strong x-text="letzshopStore.name"></strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="letzshopError">
|
|
||||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
|
||||||
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
|
||||||
<button @click="currentStep = 1"
|
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button @click="claimStore()"
|
|
||||||
:disabled="loading"
|
|
||||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
|
||||||
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ===============================================================
|
|
||||||
STEP 3: CREATE ACCOUNT
|
|
||||||
=============================================================== #}
|
|
||||||
<div x-show="currentStep === 3" class="p-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
<span class="text-red-500">*</span> Required fields
|
<span class="text-red-500">*</span> Required fields
|
||||||
@@ -187,7 +144,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Merchant Name <span class="text-red-500">*</span>
|
Business Name <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" x-model="account.merchantName" required
|
<input type="text" x-model="account.merchantName" required
|
||||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||||
@@ -218,7 +175,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<button @click="currentStep = 2"
|
<button @click="currentStep = 1"
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@@ -231,9 +188,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ===============================================================
|
{# ===============================================================
|
||||||
STEP 4: PAYMENT
|
STEP 3: PAYMENT
|
||||||
=============================================================== #}
|
=============================================================== #}
|
||||||
<div x-show="currentStep === 4" class="p-8">
|
<div x-show="currentStep === 3" class="p-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
||||||
|
|
||||||
@@ -242,7 +199,7 @@
|
|||||||
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
||||||
|
|
||||||
<div class="mt-8 flex gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<button @click="currentStep = 3"
|
<button @click="currentStep = 2"
|
||||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@@ -276,12 +233,7 @@ function signupWizard() {
|
|||||||
selectedTier: '{{ selected_tier or "professional" }}',
|
selectedTier: '{{ selected_tier or "professional" }}',
|
||||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||||
|
|
||||||
// Step 2: Letzshop
|
// Step 2: Account
|
||||||
letzshopUrl: '',
|
|
||||||
letzshopStore: null,
|
|
||||||
letzshopError: null,
|
|
||||||
|
|
||||||
// Step 3: Account
|
|
||||||
account: {
|
account: {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@@ -291,7 +243,7 @@ function signupWizard() {
|
|||||||
},
|
},
|
||||||
accountError: null,
|
accountError: null,
|
||||||
|
|
||||||
// Step 4: Payment
|
// Step 3: Payment
|
||||||
stripe: null,
|
stripe: null,
|
||||||
cardElement: null,
|
cardElement: null,
|
||||||
paymentProcessing: false,
|
paymentProcessing: false,
|
||||||
@@ -306,13 +258,10 @@ function signupWizard() {
|
|||||||
if (params.get('annual') === 'true') {
|
if (params.get('annual') === 'true') {
|
||||||
this.isAnnual = true;
|
this.isAnnual = true;
|
||||||
}
|
}
|
||||||
if (params.get('letzshop')) {
|
|
||||||
this.letzshopUrl = params.get('letzshop');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Stripe when we get to step 4
|
// Initialize Stripe when we get to step 3
|
||||||
this.$watch('currentStep', (step) => {
|
this.$watch('currentStep', (step) => {
|
||||||
if (step === 4) {
|
if (step === 3) {
|
||||||
this.initStripe();
|
this.initStripe();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -327,7 +276,8 @@ function signupWizard() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tier_code: this.selectedTier,
|
tier_code: this.selectedTier,
|
||||||
is_annual: this.isAnnual,
|
is_annual: this.isAnnual,
|
||||||
platform_code: '{{ platform.code }}'
|
platform_code: '{{ platform.code }}',
|
||||||
|
language: '{{ current_language|default("fr") }}'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -346,59 +296,6 @@ function signupWizard() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async claimStore() {
|
|
||||||
if (this.letzshopUrl.trim()) {
|
|
||||||
this.loading = true;
|
|
||||||
this.letzshopError = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First lookup the store
|
|
||||||
const lookupResponse = await fetch('/api/v1/platform/letzshop-stores/lookup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: this.letzshopUrl })
|
|
||||||
});
|
|
||||||
|
|
||||||
const lookupData = await lookupResponse.json();
|
|
||||||
|
|
||||||
if (lookupData.found && !lookupData.store.is_claimed) {
|
|
||||||
this.letzshopStore = lookupData.store;
|
|
||||||
|
|
||||||
// Claim the store
|
|
||||||
const claimResponse = await fetch('/api/v1/platform/signup/claim-store', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: this.sessionId,
|
|
||||||
letzshop_slug: lookupData.store.slug
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (claimResponse.ok) {
|
|
||||||
const claimData = await claimResponse.json();
|
|
||||||
this.account.merchantName = claimData.store_name || '';
|
|
||||||
this.currentStep = 3;
|
|
||||||
} else {
|
|
||||||
const error = await claimResponse.json();
|
|
||||||
this.letzshopError = error.detail || 'Failed to claim store';
|
|
||||||
}
|
|
||||||
} else if (lookupData.store?.is_claimed) {
|
|
||||||
this.letzshopError = 'This shop has already been claimed.';
|
|
||||||
} else {
|
|
||||||
this.letzshopError = lookupData.error || 'Shop not found.';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
this.letzshopError = 'Failed to lookup store.';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Skip this step
|
|
||||||
this.currentStep = 3;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
isAccountValid() {
|
isAccountValid() {
|
||||||
return this.account.firstName.trim() &&
|
return this.account.firstName.trim() &&
|
||||||
this.account.lastName.trim() &&
|
this.account.lastName.trim() &&
|
||||||
@@ -427,7 +324,7 @@ function signupWizard() {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
this.currentStep = 4;
|
this.currentStep = 3;
|
||||||
} else {
|
} else {
|
||||||
this.accountError = data.detail || 'Failed to create account';
|
this.accountError = data.detail || 'Failed to create account';
|
||||||
}
|
}
|
||||||
@@ -516,7 +413,6 @@ function signupWizard() {
|
|||||||
if (data.access_token) {
|
if (data.access_token) {
|
||||||
localStorage.setItem('store_token', data.access_token);
|
localStorage.setItem('store_token', data.access_token);
|
||||||
localStorage.setItem('storeCode', data.store_code);
|
localStorage.setItem('storeCode', data.store_code);
|
||||||
console.log('Store token stored for automatic login');
|
|
||||||
}
|
}
|
||||||
window.location.href = '/signup/success?store_code=' + data.store_code;
|
window.location.href = '/signup/success?store_code=' + data.store_code;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
364
app/modules/billing/tests/unit/test_signup_service.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# app/modules/billing/tests/unit/test_signup_service.py
|
||||||
|
"""Unit tests for SignupService (simplified 3-step signup)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.exceptions import (
|
||||||
|
ConflictException,
|
||||||
|
ResourceNotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
)
|
||||||
|
from app.modules.billing.models import TierCode
|
||||||
|
from app.modules.billing.services.signup_service import SignupService, _signup_sessions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_sessions():
|
||||||
|
"""Clear in-memory signup sessions before each test."""
|
||||||
|
_signup_sessions.clear()
|
||||||
|
yield
|
||||||
|
_signup_sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestSignupServiceSession:
|
||||||
|
"""Tests for SignupService session management."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = SignupService()
|
||||||
|
|
||||||
|
def test_create_session_stores_language(self):
|
||||||
|
"""create_session stores the user's browsing language."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="loyalty",
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session is not None
|
||||||
|
assert session["language"] == "de"
|
||||||
|
|
||||||
|
def test_create_session_default_language_fr(self):
|
||||||
|
"""create_session defaults to French when no language provided."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="oms",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session["language"] == "fr"
|
||||||
|
|
||||||
|
def test_create_session_stores_platform_code(self):
|
||||||
|
"""create_session stores the platform code."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.PROFESSIONAL.value,
|
||||||
|
is_annual=True,
|
||||||
|
platform_code="loyalty",
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session["platform_code"] == "loyalty"
|
||||||
|
assert session["is_annual"] is True
|
||||||
|
assert session["tier_code"] == TierCode.PROFESSIONAL.value
|
||||||
|
|
||||||
|
def test_create_session_raises_without_platform_code(self):
|
||||||
|
"""create_session raises ValidationException when platform_code is empty."""
|
||||||
|
with pytest.raises(ValidationException):
|
||||||
|
self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_session_or_raise_missing(self):
|
||||||
|
"""get_session_or_raise raises ResourceNotFoundException for invalid session."""
|
||||||
|
with pytest.raises(ResourceNotFoundException):
|
||||||
|
self.service.get_session_or_raise("nonexistent_session_id")
|
||||||
|
|
||||||
|
def test_delete_session(self):
|
||||||
|
"""delete_session removes the session from storage."""
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code="oms",
|
||||||
|
)
|
||||||
|
assert self.service.get_session(session_id) is not None
|
||||||
|
|
||||||
|
self.service.delete_session(session_id)
|
||||||
|
assert self.service.get_session(session_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestSignupServiceAccountCreation:
|
||||||
|
"""Tests for SignupService.create_account (merged store creation)."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = SignupService()
|
||||||
|
|
||||||
|
def test_create_account_creates_store(self, db):
|
||||||
|
"""create_account creates User + Merchant + Store atomically."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
# Create platform
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create a tier for the platform
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock Stripe
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_test123"
|
||||||
|
|
||||||
|
result = self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id,
|
||||||
|
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
password="securepass123", # noqa: SEC-001 # noqa: SEC-001
|
||||||
|
first_name="John",
|
||||||
|
last_name="Doe",
|
||||||
|
merchant_name="John's Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify result includes store info
|
||||||
|
assert result.user_id is not None
|
||||||
|
assert result.merchant_id is not None
|
||||||
|
assert result.store_id is not None
|
||||||
|
assert result.store_code is not None
|
||||||
|
assert result.stripe_customer_id == "cus_test123"
|
||||||
|
|
||||||
|
# Verify store was created with correct language
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||||
|
assert store is not None
|
||||||
|
assert store.name == "John's Shop"
|
||||||
|
assert store.default_language == "de"
|
||||||
|
|
||||||
|
def test_create_account_uses_session_language(self, db):
|
||||||
|
"""create_account sets store default_language from the signup session."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_test456"
|
||||||
|
|
||||||
|
result = self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id,
|
||||||
|
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="Jane",
|
||||||
|
last_name="Smith",
|
||||||
|
merchant_name="Jane's Bakery",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == result.store_id).first()
|
||||||
|
assert store.default_language == "en"
|
||||||
|
|
||||||
|
def test_create_account_rejects_duplicate_email(self, db):
|
||||||
|
"""create_account raises ConflictException for existing email."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
email = f"dup_{uuid.uuid4().hex[:8]}@example.com"
|
||||||
|
|
||||||
|
# Create first account
|
||||||
|
session_id1 = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_first"
|
||||||
|
self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id1,
|
||||||
|
email=email,
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="First",
|
||||||
|
last_name="User",
|
||||||
|
merchant_name="First Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create second account with same email
|
||||||
|
session_id2 = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ConflictException):
|
||||||
|
self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id2,
|
||||||
|
email=email,
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="Second",
|
||||||
|
last_name="User",
|
||||||
|
merchant_name="Second Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_account_updates_session(self, db):
|
||||||
|
"""create_account updates session with user/merchant/store IDs."""
|
||||||
|
from app.modules.billing.models import SubscriptionTier
|
||||||
|
from app.modules.tenancy.models import Platform
|
||||||
|
|
||||||
|
platform = Platform(
|
||||||
|
code=f"test_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tier = SubscriptionTier(
|
||||||
|
code=TierCode.ESSENTIAL.value,
|
||||||
|
name="Essential",
|
||||||
|
platform_id=platform.id,
|
||||||
|
price_monthly_cents=0,
|
||||||
|
display_order=1,
|
||||||
|
is_active=True,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
db.add(tier)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
session_id = self.service.create_session(
|
||||||
|
tier_code=TierCode.ESSENTIAL.value,
|
||||||
|
is_annual=False,
|
||||||
|
platform_code=platform.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.modules.billing.services.signup_service.stripe_service"
|
||||||
|
) as mock_stripe:
|
||||||
|
mock_stripe.create_customer_for_merchant.return_value = "cus_test789"
|
||||||
|
|
||||||
|
result = self.service.create_account(
|
||||||
|
db=db,
|
||||||
|
session_id=session_id,
|
||||||
|
email=f"test_{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
password="securepass123", # noqa: SEC-001
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
merchant_name="Test Shop",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = self.service.get_session(session_id)
|
||||||
|
assert session["user_id"] == result.user_id
|
||||||
|
assert session["merchant_id"] == result.merchant_id
|
||||||
|
assert session["store_id"] == result.store_id
|
||||||
|
assert session["store_code"] == result.store_code
|
||||||
|
assert session["stripe_customer_id"] == "cus_test789"
|
||||||
|
assert session["step"] == "account_created"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.billing
|
||||||
|
class TestSignupServicePostRedirect:
|
||||||
|
"""Tests for SignupService._get_post_signup_redirect."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = SignupService()
|
||||||
|
|
||||||
|
def test_always_redirects_to_dashboard(self, db):
|
||||||
|
"""Post-signup redirect always goes to store dashboard."""
|
||||||
|
session = {"platform_code": "loyalty"}
|
||||||
|
url = self.service._get_post_signup_redirect(db, session, "MY_STORE")
|
||||||
|
assert url == "/store/MY_STORE/dashboard"
|
||||||
|
|
||||||
|
def test_redirect_for_oms_platform(self, db):
|
||||||
|
"""OMS platform also redirects to dashboard (not onboarding wizard)."""
|
||||||
|
session = {"platform_code": "oms"}
|
||||||
|
url = self.service._get_post_signup_redirect(db, session, "OMS_STORE")
|
||||||
|
assert url == "/store/OMS_STORE/dashboard"
|
||||||
@@ -164,46 +164,13 @@ async def homepage(
|
|||||||
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
|
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
|
||||||
return templates.TemplateResponse(template_path, context)
|
return templates.TemplateResponse(template_path, context)
|
||||||
|
|
||||||
# Fallback: Default orion homepage (no CMS content)
|
# Fallback: Default homepage template with placeholder content
|
||||||
logger.info("[HOMEPAGE] No CMS homepage found, using default orion template")
|
logger.info("[HOMEPAGE] No CMS homepage found, using default template with placeholders")
|
||||||
context = get_platform_context(request, db)
|
context = get_platform_context(request, db)
|
||||||
context["tiers"] = _get_tiers_data(db)
|
context["tiers"] = _get_tiers_data(db)
|
||||||
|
|
||||||
# Add-ons (hardcoded for now, will come from DB)
|
|
||||||
context["addons"] = [
|
|
||||||
{
|
|
||||||
"code": "domain",
|
|
||||||
"name": "Custom Domain",
|
|
||||||
"description": "Use your own domain (mydomain.com)",
|
|
||||||
"price": 15,
|
|
||||||
"billing_period": "year",
|
|
||||||
"icon": "globe",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "ssl_premium",
|
|
||||||
"name": "Premium SSL",
|
|
||||||
"description": "EV certificate for trust badges",
|
|
||||||
"price": 49,
|
|
||||||
"billing_period": "year",
|
|
||||||
"icon": "shield-check",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "email",
|
|
||||||
"name": "Email Package",
|
|
||||||
"description": "Professional email addresses",
|
|
||||||
"price": 5,
|
|
||||||
"billing_period": "month",
|
|
||||||
"icon": "mail",
|
|
||||||
"options": [
|
|
||||||
{"quantity": 5, "price": 5},
|
|
||||||
{"quantity": 10, "price": 9},
|
|
||||||
{"quantity": 25, "price": 19},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"cms/platform/homepage-orion.html",
|
"cms/platform/homepage-default.html",
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -101,9 +101,17 @@ async def generic_content_page(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Resolve placeholders in store default pages ({{store_name}}, etc.)
|
||||||
|
page_content = page.content
|
||||||
|
if page.is_store_default and store:
|
||||||
|
page_content = content_page_service.resolve_placeholders(page.content, store)
|
||||||
|
|
||||||
|
context = get_storefront_context(request, db=db, page=page)
|
||||||
|
context["page_content"] = page_content
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"cms/storefront/content-page.html",
|
"cms/storefront/content-page.html",
|
||||||
get_storefront_context(request, db=db, page=page),
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from app.modules.cms.schemas.homepage_sections import (
|
|||||||
HomepageSections,
|
HomepageSections,
|
||||||
HomepageSectionsResponse,
|
HomepageSectionsResponse,
|
||||||
PricingSection,
|
PricingSection,
|
||||||
|
ProductCard,
|
||||||
|
ProductsSection,
|
||||||
# API schemas
|
# API schemas
|
||||||
SectionUpdateRequest,
|
SectionUpdateRequest,
|
||||||
# Translatable text
|
# Translatable text
|
||||||
@@ -92,6 +94,8 @@ __all__ = [
|
|||||||
"HeroSection",
|
"HeroSection",
|
||||||
"FeatureCard",
|
"FeatureCard",
|
||||||
"FeaturesSection",
|
"FeaturesSection",
|
||||||
|
"ProductCard",
|
||||||
|
"ProductsSection",
|
||||||
"PricingSection",
|
"PricingSection",
|
||||||
"CTASection",
|
"CTASection",
|
||||||
"HomepageSections",
|
"HomepageSections",
|
||||||
|
|||||||
@@ -77,6 +77,25 @@ class FeatureCard(BaseModel):
|
|||||||
description: TranslatableText = Field(default_factory=TranslatableText)
|
description: TranslatableText = Field(default_factory=TranslatableText)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCard(BaseModel):
|
||||||
|
"""Single product/offering card in products section."""
|
||||||
|
|
||||||
|
icon: str = ""
|
||||||
|
title: TranslatableText = Field(default_factory=TranslatableText)
|
||||||
|
description: TranslatableText = Field(default_factory=TranslatableText)
|
||||||
|
url: str = ""
|
||||||
|
badge: TranslatableText | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductsSection(BaseModel):
|
||||||
|
"""Product/offering showcase section (e.g. wizard.lu multi-product landing)."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
title: TranslatableText = Field(default_factory=TranslatableText)
|
||||||
|
subtitle: TranslatableText | None = None
|
||||||
|
products: list[ProductCard] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FeaturesSection(BaseModel):
|
class FeaturesSection(BaseModel):
|
||||||
"""Features section configuration."""
|
"""Features section configuration."""
|
||||||
|
|
||||||
@@ -114,6 +133,7 @@ class HomepageSections(BaseModel):
|
|||||||
"""Complete homepage sections structure."""
|
"""Complete homepage sections structure."""
|
||||||
|
|
||||||
hero: HeroSection | None = None
|
hero: HeroSection | None = None
|
||||||
|
products: ProductsSection | None = None
|
||||||
features: FeaturesSection | None = None
|
features: FeaturesSection | None = None
|
||||||
pricing: PricingSection | None = None
|
pricing: PricingSection | None = None
|
||||||
cta: CTASection | None = None
|
cta: CTASection | None = None
|
||||||
@@ -139,6 +159,10 @@ class HomepageSections(BaseModel):
|
|||||||
subtitle=make_translatable(languages),
|
subtitle=make_translatable(languages),
|
||||||
buttons=[],
|
buttons=[],
|
||||||
),
|
),
|
||||||
|
products=ProductsSection(
|
||||||
|
title=make_translatable(languages),
|
||||||
|
products=[],
|
||||||
|
),
|
||||||
features=FeaturesSection(
|
features=FeaturesSection(
|
||||||
title=make_translatable(languages),
|
title=make_translatable(languages),
|
||||||
features=[],
|
features=[],
|
||||||
@@ -162,7 +186,7 @@ class HomepageSections(BaseModel):
|
|||||||
class SectionUpdateRequest(BaseModel):
|
class SectionUpdateRequest(BaseModel):
|
||||||
"""Request to update a single section."""
|
"""Request to update a single section."""
|
||||||
|
|
||||||
section_name: str = Field(..., description="hero, features, pricing, or cta")
|
section_name: str = Field(..., description="hero, products, features, pricing, or cta")
|
||||||
section_data: dict = Field(..., description="Section configuration")
|
section_data: dict = Field(..., description="Section configuration")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ class ContentPageService:
|
|||||||
db.query(ContentPage)
|
db.query(ContentPage)
|
||||||
.filter(
|
.filter(
|
||||||
and_(
|
and_(
|
||||||
ContentPage.store_id is None,
|
ContentPage.store_id.is_(None),
|
||||||
ContentPage.is_platform_page == False,
|
ContentPage.is_platform_page.is_(False),
|
||||||
*base_filters,
|
*base_filters,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -182,12 +182,12 @@ class ContentPageService:
|
|||||||
filters = [
|
filters = [
|
||||||
ContentPage.platform_id == platform_id,
|
ContentPage.platform_id == platform_id,
|
||||||
ContentPage.slug == slug,
|
ContentPage.slug == slug,
|
||||||
ContentPage.store_id is None,
|
ContentPage.store_id.is_(None),
|
||||||
ContentPage.is_platform_page == True,
|
ContentPage.is_platform_page.is_(True),
|
||||||
]
|
]
|
||||||
|
|
||||||
if not include_unpublished:
|
if not include_unpublished:
|
||||||
filters.append(ContentPage.is_published == True)
|
filters.append(ContentPage.is_published.is_(True))
|
||||||
|
|
||||||
page = db.query(ContentPage).filter(and_(*filters)).first()
|
page = db.query(ContentPage).filter(and_(*filters)).first()
|
||||||
|
|
||||||
@@ -255,8 +255,8 @@ class ContentPageService:
|
|||||||
db.query(ContentPage)
|
db.query(ContentPage)
|
||||||
.filter(
|
.filter(
|
||||||
and_(
|
and_(
|
||||||
ContentPage.store_id is None,
|
ContentPage.store_id.is_(None),
|
||||||
ContentPage.is_platform_page == False,
|
ContentPage.is_platform_page.is_(False),
|
||||||
*base_filters,
|
*base_filters,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -298,12 +298,12 @@ class ContentPageService:
|
|||||||
"""
|
"""
|
||||||
filters = [
|
filters = [
|
||||||
ContentPage.platform_id == platform_id,
|
ContentPage.platform_id == platform_id,
|
||||||
ContentPage.store_id is None,
|
ContentPage.store_id.is_(None),
|
||||||
ContentPage.is_platform_page == True,
|
ContentPage.is_platform_page.is_(True),
|
||||||
]
|
]
|
||||||
|
|
||||||
if not include_unpublished:
|
if not include_unpublished:
|
||||||
filters.append(ContentPage.is_published == True)
|
filters.append(ContentPage.is_published.is_(True))
|
||||||
|
|
||||||
if footer_only:
|
if footer_only:
|
||||||
filters.append(ContentPage.show_in_footer == True)
|
filters.append(ContentPage.show_in_footer == True)
|
||||||
@@ -377,12 +377,12 @@ class ContentPageService:
|
|||||||
"""
|
"""
|
||||||
filters = [
|
filters = [
|
||||||
ContentPage.platform_id == platform_id,
|
ContentPage.platform_id == platform_id,
|
||||||
ContentPage.store_id is None,
|
ContentPage.store_id.is_(None),
|
||||||
ContentPage.is_platform_page == False,
|
ContentPage.is_platform_page.is_(False),
|
||||||
]
|
]
|
||||||
|
|
||||||
if not include_unpublished:
|
if not include_unpublished:
|
||||||
filters.append(ContentPage.is_published == True)
|
filters.append(ContentPage.is_published.is_(True))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
db.query(ContentPage)
|
db.query(ContentPage)
|
||||||
@@ -845,13 +845,13 @@ class ContentPageService:
|
|||||||
filters.append(ContentPage.is_published == True)
|
filters.append(ContentPage.is_published == True)
|
||||||
|
|
||||||
if page_tier == "platform":
|
if page_tier == "platform":
|
||||||
filters.append(ContentPage.is_platform_page == True)
|
filters.append(ContentPage.is_platform_page.is_(True))
|
||||||
filters.append(ContentPage.store_id is None)
|
filters.append(ContentPage.store_id.is_(None))
|
||||||
elif page_tier == "store_default":
|
elif page_tier == "store_default":
|
||||||
filters.append(ContentPage.is_platform_page == False)
|
filters.append(ContentPage.is_platform_page.is_(False))
|
||||||
filters.append(ContentPage.store_id is None)
|
filters.append(ContentPage.store_id.is_(None))
|
||||||
elif page_tier == "store_override":
|
elif page_tier == "store_override":
|
||||||
filters.append(ContentPage.store_id is not None)
|
filters.append(ContentPage.store_id.isnot(None))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
db.query(ContentPage)
|
db.query(ContentPage)
|
||||||
@@ -958,6 +958,34 @@ class ContentPageService:
|
|||||||
if not success:
|
if not success:
|
||||||
raise ContentPageNotFoundException(identifier=page_id)
|
raise ContentPageNotFoundException(identifier=page_id)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Placeholder Resolution (for store default pages)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_placeholders(content: str, store) -> str:
|
||||||
|
"""
|
||||||
|
Replace {{store_name}}, {{store_email}}, {{store_phone}} placeholders
|
||||||
|
in store default page content with actual store values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: HTML content with placeholders
|
||||||
|
store: Store object with name, contact_email, phone attributes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Content with placeholders replaced
|
||||||
|
"""
|
||||||
|
if not content or not store:
|
||||||
|
return content or ""
|
||||||
|
replacements = {
|
||||||
|
"{{store_name}}": store.name or "Our Store",
|
||||||
|
"{{store_email}}": getattr(store, "contact_email", "") or "",
|
||||||
|
"{{store_phone}}": getattr(store, "phone", "") or "",
|
||||||
|
}
|
||||||
|
for placeholder, value in replacements.items():
|
||||||
|
content = content.replace(placeholder, value)
|
||||||
|
return content
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Homepage Sections Management
|
# Homepage Sections Management
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -1032,10 +1060,12 @@ class ContentPageService:
|
|||||||
FeaturesSection,
|
FeaturesSection,
|
||||||
HeroSection,
|
HeroSection,
|
||||||
PricingSection,
|
PricingSection,
|
||||||
|
ProductsSection,
|
||||||
)
|
)
|
||||||
|
|
||||||
SECTION_SCHEMAS = {
|
SECTION_SCHEMAS = {
|
||||||
"hero": HeroSection,
|
"hero": HeroSection,
|
||||||
|
"products": ProductsSection,
|
||||||
"features": FeaturesSection,
|
"features": FeaturesSection,
|
||||||
"pricing": PricingSection,
|
"pricing": PricingSection,
|
||||||
"cta": CTASection,
|
"cta": CTASection,
|
||||||
|
|||||||
@@ -187,6 +187,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Available Placeholders (for store default pages) #}
|
||||||
|
{% set placeholders = [
|
||||||
|
('store_name', "The store's display name"),
|
||||||
|
('store_email', "The store's contact email"),
|
||||||
|
('store_phone', "The store's phone number"),
|
||||||
|
] %}
|
||||||
|
<div x-show="!form.store_id || form.store_id === 'null'" x-cloak
|
||||||
|
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center">
|
||||||
|
<span x-html="$icon('information-circle', 'w-4 h-4 mr-1.5')"></span>
|
||||||
|
Available Placeholders
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-400 mb-2">
|
||||||
|
Use these placeholders in store default pages. They will be automatically replaced with the store's actual information when displayed.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for name, description in placeholders %}
|
||||||
|
<code class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800/40 text-blue-800 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-800/60 transition-colors"
|
||||||
|
@click="navigator.clipboard.writeText('{% raw %}{{{% endraw %}{{ name }}{% raw %}}}{% endraw %}')"
|
||||||
|
title="{{ description }} — click to copy">
|
||||||
|
{% raw %}{{{% endraw %}{{ name }}{% raw %}}}{% endraw %}
|
||||||
|
</code>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-blue-600 dark:text-blue-500 mt-2">
|
||||||
|
Click a placeholder to copy it to your clipboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════ -->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
{# Import section partials #}
|
{# Import section partials #}
|
||||||
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
{% from 'cms/platform/sections/_hero.html' import render_hero %}
|
||||||
|
{% from 'cms/platform/sections/_products.html' import render_products %}
|
||||||
{% from 'cms/platform/sections/_features.html' import render_features %}
|
{% from 'cms/platform/sections/_features.html' import render_features %}
|
||||||
{% from 'cms/platform/sections/_pricing.html' import render_pricing %}
|
{% from 'cms/platform/sections/_pricing.html' import render_pricing %}
|
||||||
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
{% from 'cms/platform/sections/_cta.html' import render_cta %}
|
||||||
@@ -35,6 +36,11 @@
|
|||||||
{{ render_hero(page.sections.hero, lang, default_lang) }}
|
{{ render_hero(page.sections.hero, lang, default_lang) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Products Section #}
|
||||||
|
{% if page.sections.products %}
|
||||||
|
{{ render_products(page.sections.products, lang, default_lang) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Features Section #}
|
{# Features Section #}
|
||||||
{% if page.sections.features %}
|
{% if page.sections.features %}
|
||||||
{{ render_features(page.sections.features, lang, default_lang) }}
|
{{ render_features(page.sections.features, lang, default_lang) }}
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
{# app/templates/platform/homepage-orion.html #}
|
|
||||||
{# Orion Marketing Homepage - Letzshop OMS Platform #}
|
|
||||||
{% extends "platform/base.html" %}
|
|
||||||
{% from 'shared/macros/inputs.html' import toggle_switch %}
|
|
||||||
|
|
||||||
{% block title %}Orion - Order Management for Letzshop Sellers{% endblock %}
|
|
||||||
{% block meta_description %}Lightweight OMS for Letzshop stores. Manage orders, inventory, and invoicing. Start your 30-day free trial today.{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="homepageData()" class="bg-gray-50 dark:bg-gray-900">
|
|
||||||
|
|
||||||
{# =========================================================================
|
|
||||||
HERO SECTION
|
|
||||||
========================================================================= #}
|
|
||||||
<section class="relative overflow-hidden">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
|
|
||||||
<div class="text-center">
|
|
||||||
{# Badge #}
|
|
||||||
<div class="inline-flex items-center px-4 py-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-full text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{{ _("cms.platform.hero.badge", trial_days=trial_days) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Headline #}
|
|
||||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-extrabold text-gray-900 dark:text-white leading-tight mb-6">
|
|
||||||
{{ _("cms.platform.hero.title") }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{# Subheadline #}
|
|
||||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto mb-10">
|
|
||||||
{{ _("cms.platform.hero.subtitle") }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{# CTA Buttons #}
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<a href="/signup"
|
|
||||||
class="inline-flex items-center justify-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg shadow-indigo-500/30 transition-all hover:scale-105">
|
|
||||||
{{ _("cms.platform.hero.cta_trial") }}
|
|
||||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#find-shop"
|
|
||||||
class="inline-flex items-center justify-center px-8 py-4 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-semibold rounded-xl border-2 border-gray-200 dark:border-gray-700 hover:border-indigo-500 transition-all">
|
|
||||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _("cms.platform.hero.cta_find_shop") }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Background Decoration #}
|
|
||||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
|
||||||
<div class="absolute -top-1/2 -right-1/4 w-96 h-96 bg-indigo-200 dark:bg-indigo-900/20 rounded-full blur-3xl opacity-50"></div>
|
|
||||||
<div class="absolute -bottom-1/2 -left-1/4 w-96 h-96 bg-purple-200 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# =========================================================================
|
|
||||||
PRICING SECTION
|
|
||||||
========================================================================= #}
|
|
||||||
<section id="pricing" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
{# Section Header #}
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
{{ _("cms.platform.pricing.title") }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
|
||||||
{{ _("cms.platform.pricing.subtitle", trial_days=trial_days) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{# Billing Toggle #}
|
|
||||||
<div class="flex justify-center mt-8">
|
|
||||||
{{ toggle_switch(
|
|
||||||
model='annual',
|
|
||||||
left_label=_("cms.platform.pricing.monthly"),
|
|
||||||
right_label=_("cms.platform.pricing.annual"),
|
|
||||||
right_badge=_("cms.platform.pricing.save_months")
|
|
||||||
) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Pricing Cards Grid #}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{% for tier in tiers %}
|
|
||||||
<div class="relative bg-gray-50 dark:bg-gray-900 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
|
|
||||||
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
|
|
||||||
|
|
||||||
{# Popular Badge #}
|
|
||||||
{% if tier.is_popular %}
|
|
||||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
||||||
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
|
||||||
{{ _("cms.platform.pricing.most_popular") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Tier Name #}
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
|
|
||||||
|
|
||||||
{# Price #}
|
|
||||||
<div class="mb-6">
|
|
||||||
<template x-if="!annual">
|
|
||||||
<div>
|
|
||||||
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="annual">
|
|
||||||
<div>
|
|
||||||
{% if tier.price_annual %}
|
|
||||||
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}€</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ _("cms.platform.pricing.per_month") }}</span>
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ tier.price_annual|int }}€ {{ _("cms.platform.pricing.per_year") }}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("cms.platform.pricing.custom") }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Features List - Show all features, grey out unavailable #}
|
|
||||||
<ul class="space-y-2 mb-8 text-sm">
|
|
||||||
{# Orders #}
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% if tier.orders_per_month %}{{ _("cms.platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("cms.platform.pricing.unlimited_orders") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
{# Products #}
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% if tier.products_limit %}{{ _("cms.platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("cms.platform.pricing.unlimited_products") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
{# Team Members #}
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% if tier.team_members %}{{ _("cms.platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("cms.platform.pricing.unlimited_team") }}{% endif %}
|
|
||||||
</li>
|
|
||||||
{# Letzshop Sync - always included #}
|
|
||||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{{ _("cms.platform.pricing.letzshop_sync") }}
|
|
||||||
</li>
|
|
||||||
{# EU VAT Invoicing #}
|
|
||||||
<li class="flex items-center {% if 'invoice_eu_vat' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
|
|
||||||
{% if 'invoice_eu_vat' in tier.features %}
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
{{ _("cms.platform.pricing.eu_vat_invoicing") }}
|
|
||||||
</li>
|
|
||||||
{# Analytics Dashboard #}
|
|
||||||
<li class="flex items-center {% if 'analytics_dashboard' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
|
|
||||||
{% if 'analytics_dashboard' in tier.features %}
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
{{ _("cms.platform.pricing.analytics_dashboard") }}
|
|
||||||
</li>
|
|
||||||
{# API Access #}
|
|
||||||
<li class="flex items-center {% if 'api_access' in tier.features %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
|
|
||||||
{% if 'api_access' in tier.features %}
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
{{ _("cms.platform.pricing.api_access") }}
|
|
||||||
</li>
|
|
||||||
{# Multi-channel Integration - Enterprise only #}
|
|
||||||
<li class="flex items-center {% if tier.is_enterprise %}text-gray-700 dark:text-gray-300{% else %}text-gray-400 dark:text-gray-600{% endif %}">
|
|
||||||
{% if tier.is_enterprise %}
|
|
||||||
<svg class="w-4 h-4 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
{{ _("cms.platform.pricing.multi_channel") }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{# CTA Button #}
|
|
||||||
{% if tier.is_enterprise %}
|
|
||||||
<a href="mailto:sales@orion.lu?subject=Enterprise%20Plan%20Inquiry"
|
|
||||||
class="block w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
{{ _("cms.platform.pricing.contact_sales") }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="/signup?tier={{ tier.code }}"
|
|
||||||
:href="'/signup?tier={{ tier.code }}&annual=' + annual"
|
|
||||||
class="block w-full py-3 px-4 font-semibold rounded-xl text-center transition-colors
|
|
||||||
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-200 dark:hover:bg-indigo-900/50{% endif %}">
|
|
||||||
{{ _("cms.platform.pricing.start_trial") }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# =========================================================================
|
|
||||||
ADD-ONS SECTION
|
|
||||||
========================================================================= #}
|
|
||||||
<section id="addons" class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
{# Section Header #}
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
{{ _("cms.platform.addons.title") }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
|
||||||
{{ _("cms.platform.addons.subtitle") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Add-ons Grid #}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{% for addon in addons %}
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
|
|
||||||
{# Icon #}
|
|
||||||
<div class="w-14 h-14 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center mb-6">
|
|
||||||
{% if addon.icon == 'globe' %}
|
|
||||||
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
|
||||||
</svg>
|
|
||||||
{% elif addon.icon == 'shield-check' %}
|
|
||||||
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
|
||||||
</svg>
|
|
||||||
{% elif addon.icon == 'mail' %}
|
|
||||||
<svg class="w-7 h-7 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Name & Description #}
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ addon.name }}</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-4">{{ addon.description }}</p>
|
|
||||||
|
|
||||||
{# Price #}
|
|
||||||
<div class="flex items-baseline">
|
|
||||||
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ addon.price }}€</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400 ml-1">/{{ addon.billing_period }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Options for email packages #}
|
|
||||||
{% if addon.options %}
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
{% for opt in addon.options %}
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ opt.quantity }} addresses: {{ opt.price }}€/month
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# =========================================================================
|
|
||||||
LETZSHOP STORE FINDER
|
|
||||||
========================================================================= #}
|
|
||||||
<section id="find-shop" class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
{# Section Header #}
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
|
||||||
{{ _("cms.platform.find_shop.title") }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-lg text-gray-600 dark:text-gray-400">
|
|
||||||
{{ _("cms.platform.find_shop.subtitle") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Search Form #}
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-8 border border-gray-200 dark:border-gray-700">
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
x-model="shopUrl"
|
|
||||||
placeholder="{{ _('cms.platform.find_shop.placeholder') }}"
|
|
||||||
class="flex-1 px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="lookupStore()"
|
|
||||||
:disabled="loading"
|
|
||||||
class="px-8 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center">
|
|
||||||
<template x-if="loading">
|
|
||||||
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
{{ _("cms.platform.find_shop.button") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Result #}
|
|
||||||
<template x-if="storeResult">
|
|
||||||
<div class="mt-6 p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
||||||
<template x-if="storeResult.found">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="storeResult.store.name"></h3>
|
|
||||||
<a :href="storeResult.store.letzshop_url" target="_blank" class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline" x-text="storeResult.store.letzshop_url"></a>
|
|
||||||
</div>
|
|
||||||
<template x-if="!storeResult.store.is_claimed">
|
|
||||||
<a :href="'/signup?letzshop=' + storeResult.store.slug"
|
|
||||||
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors">
|
|
||||||
{{ _("cms.platform.find_shop.claim_shop") }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template x-if="storeResult.store.is_claimed">
|
|
||||||
<span class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-lg">
|
|
||||||
{{ _("cms.platform.find_shop.already_claimed") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!storeResult.found">
|
|
||||||
<div class="text-center text-gray-600 dark:text-gray-400">
|
|
||||||
<p x-text="storeResult.error || 'Shop not found. Please check your URL and try again.'"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
{# Help Text #}
|
|
||||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
|
|
||||||
{{ _("cms.platform.find_shop.no_account") }} <a href="https://letzshop.lu" target="_blank" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("cms.platform.find_shop.signup_letzshop") }}</a>{{ _("cms.platform.find_shop.then_connect") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# =========================================================================
|
|
||||||
FINAL CTA SECTION
|
|
||||||
========================================================================= #}
|
|
||||||
<section class="py-16 lg:py-24 bg-gradient-to-r from-indigo-600 to-purple-600">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
|
||||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-6">
|
|
||||||
{{ _("cms.platform.cta.title") }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-xl text-indigo-100 mb-10">
|
|
||||||
{{ _("cms.platform.cta.subtitle", trial_days=trial_days) }}
|
|
||||||
</p>
|
|
||||||
<a href="/signup"
|
|
||||||
class="inline-flex items-center px-10 py-4 bg-white text-indigo-600 font-bold rounded-xl shadow-lg hover:shadow-xl transition-all hover:scale-105">
|
|
||||||
{{ _("cms.platform.cta.button") }}
|
|
||||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_scripts %}
|
|
||||||
<script>
|
|
||||||
function homepageData() {
|
|
||||||
return {
|
|
||||||
annual: false,
|
|
||||||
shopUrl: '',
|
|
||||||
storeResult: null,
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
async lookupStore() {
|
|
||||||
if (!this.shopUrl.trim()) return;
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
this.storeResult = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/platform/letzshop-stores/lookup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: this.shopUrl })
|
|
||||||
});
|
|
||||||
|
|
||||||
this.storeResult = await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Lookup error:', error);
|
|
||||||
this.storeResult = { found: false, error: 'Failed to lookup. Please try again.' };
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{# app/templates/platform/sections/_products.html #}
|
||||||
|
{# Products/offerings section for multi-product platforms (e.g. wizard.lu) #}
|
||||||
|
{#
|
||||||
|
Parameters:
|
||||||
|
- products: ProductsSection object (or dict)
|
||||||
|
- lang: Current language code
|
||||||
|
- default_lang: Fallback language
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro render_products(products, lang, default_lang) %}
|
||||||
|
{% if products and products.enabled %}
|
||||||
|
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{# Section header #}
|
||||||
|
<div class="text-center mb-12">
|
||||||
|
{% set title = products.title.translations.get(lang) or products.title.translations.get(default_lang) or '' %}
|
||||||
|
{% if title %}
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if products.subtitle and products.subtitle.translations %}
|
||||||
|
{% set subtitle = products.subtitle.translations.get(lang) or products.subtitle.translations.get(default_lang) %}
|
||||||
|
{% if subtitle %}
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Product cards #}
|
||||||
|
{% if products.products %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-{{ [products.products|length, 3]|min }} gap-8">
|
||||||
|
{% for product in products.products %}
|
||||||
|
<div class="relative bg-gray-50 dark:bg-gray-700 rounded-2xl p-8 border-2 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 transition-all hover:shadow-xl group">
|
||||||
|
{# Badge #}
|
||||||
|
{% if product.badge and product.badge.translations %}
|
||||||
|
{% set badge_text = product.badge.translations.get(lang) or product.badge.translations.get(default_lang) %}
|
||||||
|
{% if badge_text %}
|
||||||
|
<div class="absolute -top-3 right-4">
|
||||||
|
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||||
|
{{ badge_text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Icon #}
|
||||||
|
{% if product.icon %}
|
||||||
|
<div class="w-14 h-14 mb-6 rounded-xl gradient-primary flex items-center justify-center">
|
||||||
|
<span x-html="typeof $icon !== 'undefined' ? $icon('{{ product.icon }}', 'w-7 h-7 text-white') : ''"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Title #}
|
||||||
|
{% set product_title = product.title.translations.get(lang) or product.title.translations.get(default_lang) or '' %}
|
||||||
|
{% if product_title %}
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||||
|
{{ product_title }}
|
||||||
|
</h3>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Description #}
|
||||||
|
{% set product_desc = product.description.translations.get(lang) or product.description.translations.get(default_lang) or '' %}
|
||||||
|
{% if product_desc %}
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{{ product_desc }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# CTA Link #}
|
||||||
|
{% if product.url %}
|
||||||
|
<a href="{{ product.url }}"
|
||||||
|
class="inline-flex items-center text-indigo-600 dark:text-indigo-400 font-semibold hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors group-hover:translate-x-1 transform transition-transform">
|
||||||
|
{% set link_text = product_title or 'Learn More' %}
|
||||||
|
Learn More
|
||||||
|
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -42,17 +42,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Content #}
|
{# Content — use page_content (with resolved placeholders) when available #}
|
||||||
|
{% set content = page_content if page_content is defined and page_content else page.content %}
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
|
||||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||||
{% if page.content_format == 'markdown' %}
|
{% if page.content_format == 'markdown' %}
|
||||||
{# Markdown content - future enhancement: render with markdown library #}
|
|
||||||
<div class="markdown-content">
|
<div class="markdown-content">
|
||||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
{{ content | safe }}{# sanitized: CMS content #}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# HTML content (default) #}
|
{{ content | safe }}{# sanitized: CMS content #}
|
||||||
{{ page.content | safe }}{# sanitized: CMS content #}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ from app.modules.contracts.metrics import (
|
|||||||
MetricsProviderProtocol,
|
MetricsProviderProtocol,
|
||||||
MetricValue,
|
MetricValue,
|
||||||
)
|
)
|
||||||
|
from app.modules.contracts.onboarding import (
|
||||||
|
OnboardingProviderProtocol,
|
||||||
|
OnboardingStepDefinition,
|
||||||
|
OnboardingStepStatus,
|
||||||
|
)
|
||||||
from app.modules.contracts.widgets import (
|
from app.modules.contracts.widgets import (
|
||||||
BreakdownWidget,
|
BreakdownWidget,
|
||||||
DashboardWidget,
|
DashboardWidget,
|
||||||
@@ -92,6 +97,10 @@ __all__ = [
|
|||||||
"MetricValue",
|
"MetricValue",
|
||||||
"MetricsContext",
|
"MetricsContext",
|
||||||
"MetricsProviderProtocol",
|
"MetricsProviderProtocol",
|
||||||
|
# Onboarding protocols
|
||||||
|
"OnboardingStepDefinition",
|
||||||
|
"OnboardingStepStatus",
|
||||||
|
"OnboardingProviderProtocol",
|
||||||
# Widget protocols
|
# Widget protocols
|
||||||
"WidgetContext",
|
"WidgetContext",
|
||||||
"WidgetListItem",
|
"WidgetListItem",
|
||||||
|
|||||||
154
app/modules/contracts/onboarding.py
Normal file
154
app/modules/contracts/onboarding.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# app/modules/contracts/onboarding.py
|
||||||
|
"""
|
||||||
|
Onboarding provider protocol for module-driven post-signup onboarding.
|
||||||
|
|
||||||
|
Each module defines its own onboarding steps (what needs to be configured)
|
||||||
|
and provides completion checks. The core module's OnboardingAggregator
|
||||||
|
discovers and aggregates all providers into a dashboard checklist.
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Modules own their onboarding steps (billing module doesn't need to know)
|
||||||
|
- Steps appear/disappear based on which modules are enabled
|
||||||
|
- Easy to add new steps (just implement protocol in your module)
|
||||||
|
- Dashboard banner guides merchants without blocking signup
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# 1. Implement the protocol in your module
|
||||||
|
class MarketplaceOnboardingProvider:
|
||||||
|
@property
|
||||||
|
def onboarding_category(self) -> str:
|
||||||
|
return "marketplace"
|
||||||
|
|
||||||
|
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||||
|
return [
|
||||||
|
OnboardingStepDefinition(
|
||||||
|
key="marketplace.connect_api",
|
||||||
|
title_key="onboarding.marketplace.connect_api.title",
|
||||||
|
description_key="onboarding.marketplace.connect_api.description",
|
||||||
|
icon="plug",
|
||||||
|
route_template="/store/{store_code}/letzshop",
|
||||||
|
order=200,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_step_completed(self, db, store_id, step_key) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 2. Register in module definition
|
||||||
|
def _get_onboarding_provider():
|
||||||
|
from app.modules.marketplace.services.marketplace_onboarding import (
|
||||||
|
marketplace_onboarding_provider,
|
||||||
|
)
|
||||||
|
return marketplace_onboarding_provider
|
||||||
|
|
||||||
|
marketplace_module = ModuleDefinition(
|
||||||
|
code="marketplace",
|
||||||
|
onboarding_provider=_get_onboarding_provider,
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Steps appear automatically in dashboard when module is enabled
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OnboardingStepDefinition:
|
||||||
|
"""
|
||||||
|
Definition of a single onboarding step.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: Unique identifier (e.g., "marketplace.connect_api")
|
||||||
|
Format: "{module}.{step_name}" for consistency
|
||||||
|
title_key: i18n key for display title
|
||||||
|
description_key: i18n key for description text
|
||||||
|
icon: Lucide icon name for UI display (e.g., "plug", "package")
|
||||||
|
route_template: URL template with {store_code} placeholder
|
||||||
|
order: Display sort order (lower = first). Default 100.
|
||||||
|
category: Grouping category (typically matches module code)
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
title_key: str
|
||||||
|
description_key: str
|
||||||
|
icon: str
|
||||||
|
route_template: str
|
||||||
|
order: int = 100
|
||||||
|
category: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OnboardingStepStatus:
|
||||||
|
"""
|
||||||
|
An onboarding step paired with its completion status.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
step: The step definition
|
||||||
|
completed: Whether the step has been completed
|
||||||
|
"""
|
||||||
|
|
||||||
|
step: OnboardingStepDefinition
|
||||||
|
completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OnboardingProviderProtocol(Protocol):
|
||||||
|
"""
|
||||||
|
Protocol for modules that provide onboarding steps.
|
||||||
|
|
||||||
|
Each module implements this to declare what setup steps are needed
|
||||||
|
after signup. The core module's OnboardingAggregator discovers and
|
||||||
|
aggregates all providers into a dashboard checklist banner.
|
||||||
|
|
||||||
|
Implementation Notes:
|
||||||
|
- Providers should be stateless (all data via db session)
|
||||||
|
- Return empty list from get_onboarding_steps() if no steps needed
|
||||||
|
- is_step_completed() should be efficient (called per step per page load)
|
||||||
|
- Use consistent key format: "{category}.{step_name}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def onboarding_category(self) -> str:
|
||||||
|
"""
|
||||||
|
Category name for this provider's onboarding steps.
|
||||||
|
|
||||||
|
Should match the module code (e.g., "marketplace", "tenancy").
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||||
|
"""
|
||||||
|
Get onboarding step definitions provided by this module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of OnboardingStepDefinition objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def is_step_completed(
|
||||||
|
self, db: "Session", store_id: int, step_key: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a specific onboarding step is completed for a store.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session for queries
|
||||||
|
store_id: ID of the store to check
|
||||||
|
step_key: The step key to check (from OnboardingStepDefinition.key)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the step is completed
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OnboardingStepDefinition",
|
||||||
|
"OnboardingStepStatus",
|
||||||
|
"OnboardingProviderProtocol",
|
||||||
|
]
|
||||||
@@ -24,6 +24,7 @@ from app.modules.core.schemas.dashboard import (
|
|||||||
StoreProductStats,
|
StoreProductStats,
|
||||||
StoreRevenueStats,
|
StoreRevenueStats,
|
||||||
)
|
)
|
||||||
|
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
|
||||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||||
from app.modules.tenancy.exceptions import StoreNotActiveException
|
from app.modules.tenancy.exceptions import StoreNotActiveException
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
@@ -124,3 +125,27 @@ def get_store_dashboard_stats(
|
|||||||
this_month=float(revenue_this_month),
|
this_month=float(revenue_this_month),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@store_dashboard_router.get("/onboarding")
|
||||||
|
def get_onboarding_status(
|
||||||
|
request: Request,
|
||||||
|
current_user: UserContext = Depends(get_current_store_api),
|
||||||
|
platform=Depends(require_platform),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get onboarding checklist status for the current store.
|
||||||
|
|
||||||
|
Returns steps from all enabled modules with completion status,
|
||||||
|
progress percentage, and whether all steps are completed.
|
||||||
|
"""
|
||||||
|
store_id = current_user.token_store_id
|
||||||
|
store = store_service.get_store_by_id(db, store_id)
|
||||||
|
|
||||||
|
return onboarding_aggregator.get_onboarding_summary(
|
||||||
|
db=db,
|
||||||
|
store_id=store_id,
|
||||||
|
platform_id=platform.id,
|
||||||
|
store_code=store.store_code,
|
||||||
|
)
|
||||||
|
|||||||
157
app/modules/core/services/onboarding_aggregator.py
Normal file
157
app/modules/core/services/onboarding_aggregator.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# app/modules/core/services/onboarding_aggregator.py
|
||||||
|
"""
|
||||||
|
Onboarding aggregator service for collecting onboarding steps from all modules.
|
||||||
|
|
||||||
|
This service lives in core because the dashboard onboarding banner is core
|
||||||
|
functionality. It discovers OnboardingProviders from all enabled modules and
|
||||||
|
provides a unified interface for the dashboard checklist.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from app.modules.core.services.onboarding_aggregator import onboarding_aggregator
|
||||||
|
|
||||||
|
summary = onboarding_aggregator.get_onboarding_summary(
|
||||||
|
db=db, store_id=123, platform_id=1, store_code="my-store"
|
||||||
|
)
|
||||||
|
# Returns: {steps, total_steps, completed_steps, progress_percentage, all_completed}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.contracts.onboarding import (
|
||||||
|
OnboardingProviderProtocol,
|
||||||
|
OnboardingStepStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.modules.base import ModuleDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingAggregatorService:
|
||||||
|
"""
|
||||||
|
Aggregates onboarding steps from all module providers.
|
||||||
|
|
||||||
|
Discovers OnboardingProviders from enabled modules and provides
|
||||||
|
a unified interface for the dashboard onboarding banner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _get_enabled_providers(
|
||||||
|
self, db: Session, platform_id: int
|
||||||
|
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
|
||||||
|
"""
|
||||||
|
Get onboarding providers from enabled modules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (module, provider) tuples for enabled modules with providers
|
||||||
|
"""
|
||||||
|
from app.modules.registry import MODULES
|
||||||
|
from app.modules.service import module_service
|
||||||
|
|
||||||
|
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
|
||||||
|
|
||||||
|
for module in MODULES.values():
|
||||||
|
if not module.has_onboarding_provider():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Core modules are always enabled, check others
|
||||||
|
if not module.is_core:
|
||||||
|
try:
|
||||||
|
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to check if module {module.code} is enabled: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = module.get_onboarding_provider_instance()
|
||||||
|
if provider is not None:
|
||||||
|
providers.append((module, provider))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to get onboarding provider for module {module.code}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return providers
|
||||||
|
|
||||||
|
def get_onboarding_steps(
|
||||||
|
self, db: Session, store_id: int, platform_id: int
|
||||||
|
) -> list[OnboardingStepStatus]:
|
||||||
|
"""
|
||||||
|
Get all onboarding steps with completion status, sorted by order.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of OnboardingStepStatus objects
|
||||||
|
"""
|
||||||
|
providers = self._get_enabled_providers(db, platform_id)
|
||||||
|
steps: list[OnboardingStepStatus] = []
|
||||||
|
|
||||||
|
for module, provider in providers:
|
||||||
|
try:
|
||||||
|
step_defs = provider.get_onboarding_steps()
|
||||||
|
for step_def in step_defs:
|
||||||
|
try:
|
||||||
|
completed = provider.is_step_completed(db, store_id, step_def.key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to check step {step_def.key} from {module.code}: {e}"
|
||||||
|
)
|
||||||
|
completed = False
|
||||||
|
steps.append(OnboardingStepStatus(step=step_def, completed=completed))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to get onboarding steps from module {module.code}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
steps.sort(key=lambda s: s.step.order)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
def get_onboarding_summary(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
store_id: int,
|
||||||
|
platform_id: int,
|
||||||
|
store_code: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get onboarding summary for the dashboard API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with: steps[], total_steps, completed_steps, progress_percentage, all_completed
|
||||||
|
"""
|
||||||
|
step_statuses = self.get_onboarding_steps(db, store_id, platform_id)
|
||||||
|
|
||||||
|
total = len(step_statuses)
|
||||||
|
completed = sum(1 for s in step_statuses if s.completed)
|
||||||
|
|
||||||
|
steps_data = []
|
||||||
|
for status in step_statuses:
|
||||||
|
route = status.step.route_template.replace("{store_code}", store_code)
|
||||||
|
steps_data.append({
|
||||||
|
"key": status.step.key,
|
||||||
|
"title_key": status.step.title_key,
|
||||||
|
"description_key": status.step.description_key,
|
||||||
|
"icon": status.step.icon,
|
||||||
|
"route": route,
|
||||||
|
"completed": status.completed,
|
||||||
|
"category": status.step.category,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"steps": steps_data,
|
||||||
|
"total_steps": total,
|
||||||
|
"completed_steps": completed,
|
||||||
|
"progress_percentage": round((completed / total * 100) if total > 0 else 0),
|
||||||
|
"all_completed": completed == total and total > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
onboarding_aggregator = OnboardingAggregatorService()
|
||||||
|
|
||||||
|
__all__ = ["OnboardingAggregatorService", "onboarding_aggregator"]
|
||||||
@@ -10,6 +10,47 @@ const storeDashLog = window.LogConfig.loggers.dashboard ||
|
|||||||
storeDashLog.info('Loading...');
|
storeDashLog.info('Loading...');
|
||||||
storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
|
storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding banner component.
|
||||||
|
* Fetches onboarding steps from API, supports session-scoped dismiss.
|
||||||
|
*/
|
||||||
|
function onboardingBanner() {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
steps: [],
|
||||||
|
totalSteps: 0,
|
||||||
|
completedSteps: 0,
|
||||||
|
progressPercentage: 0,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Check session-scoped dismiss
|
||||||
|
if (sessionStorage.getItem('onboarding_dismissed')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/store/dashboard/onboarding');
|
||||||
|
this.steps = response.steps || [];
|
||||||
|
this.totalSteps = response.total_steps || 0;
|
||||||
|
this.completedSteps = response.completed_steps || 0;
|
||||||
|
this.progressPercentage = response.progress_percentage || 0;
|
||||||
|
|
||||||
|
// Show banner only if there are incomplete steps
|
||||||
|
if (this.totalSteps > 0 && !response.all_completed) {
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
storeDashLog.error('Failed to load onboarding status', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
sessionStorage.setItem('onboarding_dismissed', 'true');
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function storeDashboard() {
|
function storeDashboard() {
|
||||||
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
|
storeDashLog.info('[STORE DASHBOARD] storeDashboard() called');
|
||||||
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);
|
storeDashLog.info('[STORE DASHBOARD] data function exists inside?', typeof data);
|
||||||
|
|||||||
@@ -8,12 +8,15 @@
|
|||||||
|
|
||||||
{% block alpine_data %}storeDashboard(){% endblock %}
|
{% block alpine_data %}storeDashboard(){% endblock %}
|
||||||
|
|
||||||
{% from "shared/macros/feature_gate.html" import email_settings_warning %}
|
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Email Settings Warning -->
|
<!-- Email Settings Warning -->
|
||||||
{{ email_settings_warning() }}
|
{{ email_settings_warning() }}
|
||||||
|
|
||||||
|
<!-- Onboarding Banner -->
|
||||||
|
{{ onboarding_banner() }}
|
||||||
|
|
||||||
<!-- Limit Warnings -->
|
<!-- Limit Warnings -->
|
||||||
{{ limit_warning("orders") }}
|
{{ limit_warning("orders") }}
|
||||||
{{ limit_warning("products") }}
|
{{ limit_warning("products") }}
|
||||||
|
|||||||
215
app/modules/core/tests/integration/test_onboarding_routes.py
Normal file
215
app/modules/core/tests/integration/test_onboarding_routes.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# app/modules/core/tests/integration/test_onboarding_routes.py
|
||||||
|
"""
|
||||||
|
Integration tests for the onboarding API endpoint.
|
||||||
|
|
||||||
|
Tests the store dashboard onboarding endpoint at:
|
||||||
|
GET /api/v1/store/dashboard/onboarding
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Endpoint returns onboarding steps with completion status
|
||||||
|
- Endpoint requires store authentication
|
||||||
|
- Response structure matches expected schema
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.deps import get_current_store_api
|
||||||
|
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||||
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_owner(db):
|
||||||
|
"""Create a store owner user for onboarding tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
user = User(
|
||||||
|
email=f"onbroute_{uuid.uuid4().hex[:8]}@test.com",
|
||||||
|
username=f"onbroute_{uuid.uuid4().hex[:8]}",
|
||||||
|
hashed_password=auth.hash_password("pass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_platform(db):
|
||||||
|
"""Create a platform for onboarding tests."""
|
||||||
|
platform = Platform(
|
||||||
|
code=f"onbp_{uuid.uuid4().hex[:8]}",
|
||||||
|
name="Onboarding Test Platform",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(platform)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(platform)
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_merchant(db, onb_owner):
|
||||||
|
"""Create a merchant for onboarding tests."""
|
||||||
|
merchant = Merchant(
|
||||||
|
name="Onboarding Route Test Merchant",
|
||||||
|
owner_user_id=onb_owner.id,
|
||||||
|
contact_email=onb_owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_store(db, onb_merchant):
|
||||||
|
"""Create a store for onboarding tests."""
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
store = Store(
|
||||||
|
merchant_id=onb_merchant.id,
|
||||||
|
store_code=f"ONBROUTE_{uid.upper()}",
|
||||||
|
subdomain=f"onbroute{uid.lower()}",
|
||||||
|
name="Onboarding Route Test Store",
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(store)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(store)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_store_platform(db, onb_store, onb_platform):
|
||||||
|
"""Link store to platform."""
|
||||||
|
sp = StorePlatform(
|
||||||
|
store_id=onb_store.id,
|
||||||
|
platform_id=onb_platform.id,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(sp)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sp)
|
||||||
|
return sp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_auth(onb_owner, onb_store):
|
||||||
|
"""Override auth dependency for store API auth."""
|
||||||
|
user_context = UserContext(
|
||||||
|
id=onb_owner.id,
|
||||||
|
email=onb_owner.email,
|
||||||
|
username=onb_owner.username,
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
token_store_id=onb_store.id,
|
||||||
|
token_store_code=onb_store.store_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _override():
|
||||||
|
return user_context
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_store_api] = _override
|
||||||
|
yield {"Authorization": "Bearer fake-token"}
|
||||||
|
app.dependency_overrides.pop(get_current_store_api, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.core
|
||||||
|
class TestOnboardingEndpoint:
|
||||||
|
"""Tests for GET /api/v1/store/dashboard/onboarding."""
|
||||||
|
|
||||||
|
def test_returns_onboarding_summary(
|
||||||
|
self, client, db, onb_auth, onb_store, onb_store_platform
|
||||||
|
):
|
||||||
|
"""Endpoint returns onboarding summary with expected structure."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/store/dashboard/onboarding",
|
||||||
|
headers=onb_auth,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
assert "steps" in data
|
||||||
|
assert "total_steps" in data
|
||||||
|
assert "completed_steps" in data
|
||||||
|
assert "progress_percentage" in data
|
||||||
|
assert "all_completed" in data
|
||||||
|
assert isinstance(data["steps"], list)
|
||||||
|
assert isinstance(data["total_steps"], int)
|
||||||
|
assert isinstance(data["completed_steps"], int)
|
||||||
|
|
||||||
|
def test_includes_tenancy_step(
|
||||||
|
self, client, db, onb_auth, onb_store, onb_store_platform
|
||||||
|
):
|
||||||
|
"""Response includes the tenancy customize_store step (always present)."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/store/dashboard/onboarding",
|
||||||
|
headers=onb_auth,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
step_keys = [s["key"] for s in data["steps"]]
|
||||||
|
assert "tenancy.customize_store" in step_keys
|
||||||
|
|
||||||
|
def test_step_data_has_required_fields(
|
||||||
|
self, client, db, onb_auth, onb_store, onb_store_platform
|
||||||
|
):
|
||||||
|
"""Each step has key, title_key, description_key, icon, route, completed."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/store/dashboard/onboarding",
|
||||||
|
headers=onb_auth,
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
for step in data["steps"]:
|
||||||
|
assert "key" in step
|
||||||
|
assert "title_key" in step
|
||||||
|
assert "description_key" in step
|
||||||
|
assert "icon" in step
|
||||||
|
assert "route" in step
|
||||||
|
assert "completed" in step
|
||||||
|
assert isinstance(step["completed"], bool)
|
||||||
|
|
||||||
|
def test_routes_contain_store_code(
|
||||||
|
self, client, db, onb_auth, onb_store, onb_store_platform
|
||||||
|
):
|
||||||
|
"""Step routes have {store_code} replaced with the actual store code."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/store/dashboard/onboarding",
|
||||||
|
headers=onb_auth,
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
for step in data["steps"]:
|
||||||
|
assert "{store_code}" not in step["route"]
|
||||||
|
if "/store/" in step["route"]:
|
||||||
|
assert onb_store.store_code in step["route"]
|
||||||
|
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
"""Returns 401 without authentication."""
|
||||||
|
app.dependency_overrides.pop(get_current_store_api, None)
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/store/dashboard/onboarding",
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
275
app/modules/core/tests/unit/test_onboarding_aggregator.py
Normal file
275
app/modules/core/tests/unit/test_onboarding_aggregator.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# app/modules/core/tests/unit/test_onboarding_aggregator.py
|
||||||
|
"""Unit tests for OnboardingAggregatorService."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.contracts.onboarding import (
|
||||||
|
OnboardingStepDefinition,
|
||||||
|
OnboardingStepStatus,
|
||||||
|
)
|
||||||
|
from app.modules.core.services.onboarding_aggregator import OnboardingAggregatorService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_step(key: str, order: int = 100, category: str = "test") -> OnboardingStepDefinition:
|
||||||
|
return OnboardingStepDefinition(
|
||||||
|
key=key,
|
||||||
|
title_key=f"{key}.title",
|
||||||
|
description_key=f"{key}.desc",
|
||||||
|
icon="circle",
|
||||||
|
route_template="/store/{store_code}/test",
|
||||||
|
order=order,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.core
|
||||||
|
class TestOnboardingAggregatorSteps:
|
||||||
|
"""Tests for OnboardingAggregatorService.get_onboarding_steps."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = OnboardingAggregatorService()
|
||||||
|
|
||||||
|
def test_aggregates_steps_from_multiple_providers(self, db):
|
||||||
|
"""Collects steps from all enabled module providers."""
|
||||||
|
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
|
||||||
|
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
|
||||||
|
|
||||||
|
provider_a = MagicMock()
|
||||||
|
provider_a.onboarding_category = "tenancy"
|
||||||
|
provider_a.get_onboarding_steps.return_value = [step_a]
|
||||||
|
provider_a.is_step_completed.return_value = True
|
||||||
|
|
||||||
|
provider_b = MagicMock()
|
||||||
|
provider_b.onboarding_category = "marketplace"
|
||||||
|
provider_b.get_onboarding_steps.return_value = [step_b]
|
||||||
|
provider_b.is_step_completed.return_value = False
|
||||||
|
|
||||||
|
module_a = MagicMock()
|
||||||
|
module_a.code = "tenancy"
|
||||||
|
module_b = MagicMock()
|
||||||
|
module_b.code = "marketplace"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service,
|
||||||
|
"_get_enabled_providers",
|
||||||
|
return_value=[(module_a, provider_a), (module_b, provider_b)],
|
||||||
|
):
|
||||||
|
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
|
||||||
|
|
||||||
|
assert len(steps) == 2
|
||||||
|
assert steps[0].step.key == "tenancy.customize"
|
||||||
|
assert steps[0].completed is True
|
||||||
|
assert steps[1].step.key == "marketplace.connect"
|
||||||
|
assert steps[1].completed is False
|
||||||
|
|
||||||
|
def test_steps_sorted_by_order(self, db):
|
||||||
|
"""Steps are returned sorted by order regardless of provider order."""
|
||||||
|
step_high = _make_step("loyalty.program", order=300)
|
||||||
|
step_low = _make_step("tenancy.customize", order=100)
|
||||||
|
|
||||||
|
provider_high = MagicMock()
|
||||||
|
provider_high.onboarding_category = "loyalty"
|
||||||
|
provider_high.get_onboarding_steps.return_value = [step_high]
|
||||||
|
provider_high.is_step_completed.return_value = False
|
||||||
|
|
||||||
|
provider_low = MagicMock()
|
||||||
|
provider_low.onboarding_category = "tenancy"
|
||||||
|
provider_low.get_onboarding_steps.return_value = [step_low]
|
||||||
|
provider_low.is_step_completed.return_value = False
|
||||||
|
|
||||||
|
module_high = MagicMock()
|
||||||
|
module_high.code = "loyalty"
|
||||||
|
module_low = MagicMock()
|
||||||
|
module_low.code = "tenancy"
|
||||||
|
|
||||||
|
# Provide high-order first
|
||||||
|
with patch.object(
|
||||||
|
self.service,
|
||||||
|
"_get_enabled_providers",
|
||||||
|
return_value=[(module_high, provider_high), (module_low, provider_low)],
|
||||||
|
):
|
||||||
|
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
|
||||||
|
|
||||||
|
assert steps[0].step.key == "tenancy.customize"
|
||||||
|
assert steps[1].step.key == "loyalty.program"
|
||||||
|
|
||||||
|
def test_empty_when_no_providers(self, db):
|
||||||
|
"""Returns empty list when no modules have onboarding providers."""
|
||||||
|
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
|
||||||
|
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
|
||||||
|
|
||||||
|
assert steps == []
|
||||||
|
|
||||||
|
def test_handles_provider_error_gracefully(self, db):
|
||||||
|
"""Skips a provider that raises an exception."""
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.onboarding_category = "broken"
|
||||||
|
provider.get_onboarding_steps.side_effect = RuntimeError("DB error")
|
||||||
|
module = MagicMock()
|
||||||
|
module.code = "broken"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||||
|
):
|
||||||
|
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
|
||||||
|
|
||||||
|
assert steps == []
|
||||||
|
|
||||||
|
def test_handles_step_completion_check_error(self, db):
|
||||||
|
"""Marks step as incomplete when completion check raises."""
|
||||||
|
step = _make_step("test.step")
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.onboarding_category = "test"
|
||||||
|
provider.get_onboarding_steps.return_value = [step]
|
||||||
|
provider.is_step_completed.side_effect = RuntimeError("query error")
|
||||||
|
module = MagicMock()
|
||||||
|
module.code = "test"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||||
|
):
|
||||||
|
steps = self.service.get_onboarding_steps(db, store_id=1, platform_id=1)
|
||||||
|
|
||||||
|
assert len(steps) == 1
|
||||||
|
assert steps[0].completed is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.core
|
||||||
|
class TestOnboardingAggregatorSummary:
|
||||||
|
"""Tests for OnboardingAggregatorService.get_onboarding_summary."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.service = OnboardingAggregatorService()
|
||||||
|
|
||||||
|
def test_summary_with_mixed_completion(self, db):
|
||||||
|
"""Returns correct progress for partially completed onboarding."""
|
||||||
|
step_a = _make_step("tenancy.customize", order=100, category="tenancy")
|
||||||
|
step_b = _make_step("marketplace.connect", order=200, category="marketplace")
|
||||||
|
step_c = _make_step("marketplace.import", order=210, category="marketplace")
|
||||||
|
|
||||||
|
provider_a = MagicMock()
|
||||||
|
provider_a.onboarding_category = "tenancy"
|
||||||
|
provider_a.get_onboarding_steps.return_value = [step_a]
|
||||||
|
provider_a.is_step_completed.return_value = True
|
||||||
|
|
||||||
|
provider_b = MagicMock()
|
||||||
|
provider_b.onboarding_category = "marketplace"
|
||||||
|
provider_b.get_onboarding_steps.return_value = [step_b, step_c]
|
||||||
|
provider_b.is_step_completed.return_value = False
|
||||||
|
|
||||||
|
module_a = MagicMock()
|
||||||
|
module_a.code = "tenancy"
|
||||||
|
module_b = MagicMock()
|
||||||
|
module_b.code = "marketplace"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service,
|
||||||
|
"_get_enabled_providers",
|
||||||
|
return_value=[(module_a, provider_a), (module_b, provider_b)],
|
||||||
|
):
|
||||||
|
summary = self.service.get_onboarding_summary(
|
||||||
|
db, store_id=1, platform_id=1, store_code="MYSTORE"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["total_steps"] == 3
|
||||||
|
assert summary["completed_steps"] == 1
|
||||||
|
assert summary["progress_percentage"] == 33
|
||||||
|
assert summary["all_completed"] is False
|
||||||
|
assert len(summary["steps"]) == 3
|
||||||
|
|
||||||
|
def test_summary_all_completed(self, db):
|
||||||
|
"""Returns all_completed=True when every step is done."""
|
||||||
|
step = _make_step("tenancy.customize")
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.onboarding_category = "tenancy"
|
||||||
|
provider.get_onboarding_steps.return_value = [step]
|
||||||
|
provider.is_step_completed.return_value = True
|
||||||
|
module = MagicMock()
|
||||||
|
module.code = "tenancy"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||||
|
):
|
||||||
|
summary = self.service.get_onboarding_summary(
|
||||||
|
db, store_id=1, platform_id=1, store_code="TEST"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["all_completed"] is True
|
||||||
|
assert summary["progress_percentage"] == 100
|
||||||
|
|
||||||
|
def test_summary_empty_providers(self, db):
|
||||||
|
"""Returns zeros and all_completed=False when no providers."""
|
||||||
|
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
|
||||||
|
summary = self.service.get_onboarding_summary(
|
||||||
|
db, store_id=1, platform_id=1, store_code="TEST"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["total_steps"] == 0
|
||||||
|
assert summary["completed_steps"] == 0
|
||||||
|
assert summary["progress_percentage"] == 0
|
||||||
|
assert summary["all_completed"] is False
|
||||||
|
|
||||||
|
def test_summary_replaces_store_code_in_routes(self, db):
|
||||||
|
"""Route templates have {store_code} replaced with actual store code."""
|
||||||
|
step = OnboardingStepDefinition(
|
||||||
|
key="test.step",
|
||||||
|
title_key="test.title",
|
||||||
|
description_key="test.desc",
|
||||||
|
icon="circle",
|
||||||
|
route_template="/store/{store_code}/settings",
|
||||||
|
order=100,
|
||||||
|
category="test",
|
||||||
|
)
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.onboarding_category = "test"
|
||||||
|
provider.get_onboarding_steps.return_value = [step]
|
||||||
|
provider.is_step_completed.return_value = False
|
||||||
|
module = MagicMock()
|
||||||
|
module.code = "test"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||||
|
):
|
||||||
|
summary = self.service.get_onboarding_summary(
|
||||||
|
db, store_id=1, platform_id=1, store_code="MY_STORE"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["steps"][0]["route"] == "/store/MY_STORE/settings"
|
||||||
|
|
||||||
|
def test_summary_step_data_includes_all_fields(self, db):
|
||||||
|
"""Each step in summary includes key, title_key, description_key, icon, route, completed, category."""
|
||||||
|
step = OnboardingStepDefinition(
|
||||||
|
key="test.step",
|
||||||
|
title_key="test.title",
|
||||||
|
description_key="test.desc",
|
||||||
|
icon="settings",
|
||||||
|
route_template="/store/{store_code}/test",
|
||||||
|
order=100,
|
||||||
|
category="test_cat",
|
||||||
|
)
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.onboarding_category = "test"
|
||||||
|
provider.get_onboarding_steps.return_value = [step]
|
||||||
|
provider.is_step_completed.return_value = True
|
||||||
|
module = MagicMock()
|
||||||
|
module.code = "test"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||||
|
):
|
||||||
|
summary = self.service.get_onboarding_summary(
|
||||||
|
db, store_id=1, platform_id=1, store_code="S1"
|
||||||
|
)
|
||||||
|
|
||||||
|
step_data = summary["steps"][0]
|
||||||
|
assert step_data["key"] == "test.step"
|
||||||
|
assert step_data["title_key"] == "test.title"
|
||||||
|
assert step_data["description_key"] == "test.desc"
|
||||||
|
assert step_data["icon"] == "settings"
|
||||||
|
assert step_data["route"] == "/store/S1/test"
|
||||||
|
assert step_data["completed"] is True
|
||||||
|
assert step_data["category"] == "test_cat"
|
||||||
@@ -58,6 +58,15 @@ def _get_feature_provider():
|
|||||||
return loyalty_feature_provider
|
return loyalty_feature_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_onboarding_provider():
|
||||||
|
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||||
|
from app.modules.loyalty.services.loyalty_onboarding import (
|
||||||
|
loyalty_onboarding_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return loyalty_onboarding_provider
|
||||||
|
|
||||||
|
|
||||||
# Loyalty module definition
|
# Loyalty module definition
|
||||||
loyalty_module = ModuleDefinition(
|
loyalty_module = ModuleDefinition(
|
||||||
code="loyalty",
|
code="loyalty",
|
||||||
@@ -260,6 +269,8 @@ loyalty_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
# Feature provider for billing feature gating
|
# Feature provider for billing feature gating
|
||||||
feature_provider=_get_feature_provider,
|
feature_provider=_get_feature_provider,
|
||||||
|
# Onboarding provider for post-signup checklist
|
||||||
|
onboarding_provider=_get_onboarding_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
61
app/modules/loyalty/services/loyalty_onboarding.py
Normal file
61
app/modules/loyalty/services/loyalty_onboarding.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# app/modules/loyalty/services/loyalty_onboarding.py
|
||||||
|
"""
|
||||||
|
Onboarding provider for the loyalty module.
|
||||||
|
|
||||||
|
Provides the "Create your first loyalty program" step.
|
||||||
|
Completed when at least 1 LoyaltyProgram exists for the store's merchant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.contracts.onboarding import OnboardingStepDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyOnboardingProvider:
|
||||||
|
"""Onboarding provider for loyalty module."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def onboarding_category(self) -> str:
|
||||||
|
return "loyalty"
|
||||||
|
|
||||||
|
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||||
|
return [
|
||||||
|
OnboardingStepDefinition(
|
||||||
|
key="loyalty.create_program",
|
||||||
|
title_key="onboarding.loyalty.create_program.title",
|
||||||
|
description_key="onboarding.loyalty.create_program.description",
|
||||||
|
icon="gift",
|
||||||
|
route_template="/store/{store_code}/loyalty/programs",
|
||||||
|
order=300,
|
||||||
|
category="loyalty",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_step_completed(
|
||||||
|
self, db: Session, store_id: int, step_key: str
|
||||||
|
) -> bool:
|
||||||
|
if step_key != "loyalty.create_program":
|
||||||
|
return False
|
||||||
|
|
||||||
|
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram
|
||||||
|
from app.modules.tenancy.models.store import Store
|
||||||
|
|
||||||
|
# Programs belong to merchant, not store — join through store
|
||||||
|
store = db.query(Store).filter(Store.id == store_id).first()
|
||||||
|
if not store:
|
||||||
|
return False
|
||||||
|
|
||||||
|
count = (
|
||||||
|
db.query(LoyaltyProgram)
|
||||||
|
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
|
||||||
|
.limit(1)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
|
||||||
|
loyalty_onboarding_provider = LoyaltyOnboardingProvider()
|
||||||
125
app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
Normal file
125
app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
|
||||||
|
"""Unit tests for LoyaltyOnboardingProvider."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram, LoyaltyType
|
||||||
|
from app.modules.loyalty.services.loyalty_onboarding import LoyaltyOnboardingProvider
|
||||||
|
from app.modules.tenancy.models import Merchant, Store, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loy_owner(db):
|
||||||
|
"""Create a store owner for loyalty onboarding tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
user = User(
|
||||||
|
email=f"loyonb_{uuid.uuid4().hex[:8]}@test.com",
|
||||||
|
username=f"loyonb_{uuid.uuid4().hex[:8]}",
|
||||||
|
hashed_password=auth.hash_password("pass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loy_merchant(db, loy_owner):
|
||||||
|
"""Create a merchant for loyalty onboarding tests."""
|
||||||
|
merchant = Merchant(
|
||||||
|
name="Loyalty Onboarding Merchant",
|
||||||
|
owner_user_id=loy_owner.id,
|
||||||
|
contact_email=loy_owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def loy_store(db, loy_merchant):
|
||||||
|
"""Create a store (no loyalty program) for loyalty onboarding tests."""
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
store = Store(
|
||||||
|
merchant_id=loy_merchant.id,
|
||||||
|
store_code=f"LOYONB_{uid.upper()}",
|
||||||
|
subdomain=f"loyonb{uid.lower()}",
|
||||||
|
name="Loyalty Onboarding Store",
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(store)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(store)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.loyalty
|
||||||
|
class TestLoyaltyOnboardingProvider:
|
||||||
|
"""Tests for LoyaltyOnboardingProvider."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.provider = LoyaltyOnboardingProvider()
|
||||||
|
|
||||||
|
def test_category(self):
|
||||||
|
"""Returns 'loyalty' as the onboarding category."""
|
||||||
|
assert self.provider.onboarding_category == "loyalty"
|
||||||
|
|
||||||
|
def test_get_onboarding_steps_returns_one_step(self):
|
||||||
|
"""Returns exactly one step: create_program."""
|
||||||
|
steps = self.provider.get_onboarding_steps()
|
||||||
|
assert len(steps) == 1
|
||||||
|
assert steps[0].key == "loyalty.create_program"
|
||||||
|
assert steps[0].order == 300
|
||||||
|
|
||||||
|
def test_incomplete_without_program(self, db, loy_store):
|
||||||
|
"""Step is incomplete when merchant has no loyalty programs."""
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, loy_store.id, "loyalty.create_program"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_complete_with_program(self, db, loy_store, loy_merchant):
|
||||||
|
"""Step is complete when merchant has at least one loyalty program."""
|
||||||
|
program = LoyaltyProgram(
|
||||||
|
merchant_id=loy_merchant.id,
|
||||||
|
loyalty_type=LoyaltyType.STAMPS.value,
|
||||||
|
stamps_target=10,
|
||||||
|
cooldown_minutes=0,
|
||||||
|
max_daily_stamps=10,
|
||||||
|
require_staff_pin=False,
|
||||||
|
card_name="Test Card",
|
||||||
|
card_color="#FF0000",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(program)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, loy_store.id, "loyalty.create_program"
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_nonexistent_store_returns_false(self, db):
|
||||||
|
"""Returns False for a store ID that doesn't exist."""
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, 999999, "loyalty.create_program"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_unknown_step_key_returns_false(self, db, loy_store):
|
||||||
|
"""Returns False for an unrecognized step key."""
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, loy_store.id, "loyalty.unknown_step"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
@@ -59,6 +59,15 @@ def _get_feature_provider():
|
|||||||
return marketplace_feature_provider
|
return marketplace_feature_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_onboarding_provider():
|
||||||
|
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||||
|
from app.modules.marketplace.services.marketplace_onboarding import (
|
||||||
|
marketplace_onboarding_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return marketplace_onboarding_provider
|
||||||
|
|
||||||
|
|
||||||
# Marketplace module definition
|
# Marketplace module definition
|
||||||
marketplace_module = ModuleDefinition(
|
marketplace_module = ModuleDefinition(
|
||||||
code="marketplace",
|
code="marketplace",
|
||||||
@@ -186,6 +195,8 @@ marketplace_module = ModuleDefinition(
|
|||||||
widget_provider=_get_widget_provider,
|
widget_provider=_get_widget_provider,
|
||||||
# Feature provider for feature flags
|
# Feature provider for feature flags
|
||||||
feature_provider=_get_feature_provider,
|
feature_provider=_get_feature_provider,
|
||||||
|
# Onboarding provider for post-signup checklist
|
||||||
|
onboarding_provider=_get_onboarding_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,15 +95,7 @@ async def store_marketplace_page(
|
|||||||
"""
|
"""
|
||||||
Render marketplace import page.
|
Render marketplace import page.
|
||||||
JavaScript loads import jobs and products via API.
|
JavaScript loads import jobs and products via API.
|
||||||
Redirects to onboarding if not completed.
|
|
||||||
"""
|
"""
|
||||||
onboarding_service = OnboardingService(db)
|
|
||||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/store/{store_code}/onboarding",
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"marketplace/store/marketplace.html",
|
"marketplace/store/marketplace.html",
|
||||||
get_store_context(request, db, current_user, store_code),
|
get_store_context(request, db, current_user, store_code),
|
||||||
@@ -127,15 +119,7 @@ async def store_letzshop_page(
|
|||||||
"""
|
"""
|
||||||
Render Letzshop integration page.
|
Render Letzshop integration page.
|
||||||
JavaScript loads orders, credentials status, and handles fulfillment operations.
|
JavaScript loads orders, credentials status, and handles fulfillment operations.
|
||||||
Redirects to onboarding if not completed.
|
|
||||||
"""
|
"""
|
||||||
onboarding_service = OnboardingService(db)
|
|
||||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/store/{store_code}/onboarding",
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"marketplace/store/letzshop.html",
|
"marketplace/store/letzshop.html",
|
||||||
get_store_context(request, db, current_user, store_code),
|
get_store_context(request, db, current_user, store_code),
|
||||||
|
|||||||
79
app/modules/marketplace/services/marketplace_onboarding.py
Normal file
79
app/modules/marketplace/services/marketplace_onboarding.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# app/modules/marketplace/services/marketplace_onboarding.py
|
||||||
|
"""
|
||||||
|
Onboarding provider for the marketplace module.
|
||||||
|
|
||||||
|
Provides two onboarding steps:
|
||||||
|
1. Connect Letzshop API - completed when StoreLetzshopCredentials exists with api_key
|
||||||
|
2. Import products - completed when at least 1 product exists in catalog
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.contracts.onboarding import OnboardingStepDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketplaceOnboardingProvider:
|
||||||
|
"""Onboarding provider for marketplace module."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def onboarding_category(self) -> str:
|
||||||
|
return "marketplace"
|
||||||
|
|
||||||
|
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||||
|
return [
|
||||||
|
OnboardingStepDefinition(
|
||||||
|
key="marketplace.connect_api",
|
||||||
|
title_key="onboarding.marketplace.connect_api.title",
|
||||||
|
description_key="onboarding.marketplace.connect_api.description",
|
||||||
|
icon="plug",
|
||||||
|
route_template="/store/{store_code}/letzshop",
|
||||||
|
order=200,
|
||||||
|
category="marketplace",
|
||||||
|
),
|
||||||
|
OnboardingStepDefinition(
|
||||||
|
key="marketplace.import_products",
|
||||||
|
title_key="onboarding.marketplace.import_products.title",
|
||||||
|
description_key="onboarding.marketplace.import_products.description",
|
||||||
|
icon="package",
|
||||||
|
route_template="/store/{store_code}/marketplace",
|
||||||
|
order=210,
|
||||||
|
category="marketplace",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_step_completed(
|
||||||
|
self, db: Session, store_id: int, step_key: str
|
||||||
|
) -> bool:
|
||||||
|
if step_key == "marketplace.connect_api":
|
||||||
|
return self._has_letzshop_credentials(db, store_id)
|
||||||
|
if step_key == "marketplace.import_products":
|
||||||
|
return self._has_products(db, store_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _has_letzshop_credentials(self, db: Session, store_id: int) -> bool:
|
||||||
|
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
|
||||||
|
|
||||||
|
creds = (
|
||||||
|
db.query(StoreLetzshopCredentials)
|
||||||
|
.filter(StoreLetzshopCredentials.store_id == store_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return bool(creds and creds.api_key_encrypted)
|
||||||
|
|
||||||
|
def _has_products(self, db: Session, store_id: int) -> bool:
|
||||||
|
from app.modules.catalog.models.product import Product
|
||||||
|
|
||||||
|
count = (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.store_id == store_id)
|
||||||
|
.limit(1)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
|
||||||
|
marketplace_onboarding_provider = MarketplaceOnboardingProvider()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# app/modules/marketplace/tests/unit/test_marketplace_onboarding.py
|
||||||
|
"""Unit tests for MarketplaceOnboardingProvider."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.marketplace.services.marketplace_onboarding import (
|
||||||
|
MarketplaceOnboardingProvider,
|
||||||
|
)
|
||||||
|
from app.modules.tenancy.models import Merchant, Store, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mp_owner(db):
|
||||||
|
"""Create a store owner for marketplace onboarding tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
user = User(
|
||||||
|
email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||||
|
username=f"mpowner_{uuid.uuid4().hex[:8]}",
|
||||||
|
hashed_password=auth.hash_password("pass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mp_merchant(db, mp_owner):
|
||||||
|
"""Create a merchant for marketplace onboarding tests."""
|
||||||
|
merchant = Merchant(
|
||||||
|
name="MP Onboarding Merchant",
|
||||||
|
owner_user_id=mp_owner.id,
|
||||||
|
contact_email=mp_owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mp_store(db, mp_merchant):
|
||||||
|
"""Create a store for marketplace onboarding tests."""
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
store = Store(
|
||||||
|
merchant_id=mp_merchant.id,
|
||||||
|
store_code=f"MPSTORE_{uid.upper()}",
|
||||||
|
subdomain=f"mpstore{uid.lower()}",
|
||||||
|
name="MP Test Store",
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(store)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(store)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.marketplace
|
||||||
|
class TestMarketplaceOnboardingProvider:
|
||||||
|
"""Tests for MarketplaceOnboardingProvider."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.provider = MarketplaceOnboardingProvider()
|
||||||
|
|
||||||
|
def test_category(self):
|
||||||
|
"""Returns 'marketplace' as the onboarding category."""
|
||||||
|
assert self.provider.onboarding_category == "marketplace"
|
||||||
|
|
||||||
|
def test_get_onboarding_steps_returns_two_steps(self):
|
||||||
|
"""Returns two steps: connect_api and import_products."""
|
||||||
|
steps = self.provider.get_onboarding_steps()
|
||||||
|
assert len(steps) == 2
|
||||||
|
assert steps[0].key == "marketplace.connect_api"
|
||||||
|
assert steps[0].order == 200
|
||||||
|
assert steps[1].key == "marketplace.import_products"
|
||||||
|
assert steps[1].order == 210
|
||||||
|
|
||||||
|
def test_connect_api_incomplete_without_credentials(self, db, mp_store):
|
||||||
|
"""connect_api step is incomplete when no credentials exist."""
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, mp_store.id, "marketplace.connect_api"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_connect_api_complete_with_credentials(self, db, mp_store):
|
||||||
|
"""connect_api step is complete when StoreLetzshopCredentials has api_key."""
|
||||||
|
from app.modules.marketplace.models.letzshop import StoreLetzshopCredentials
|
||||||
|
|
||||||
|
creds = StoreLetzshopCredentials(
|
||||||
|
store_id=mp_store.id,
|
||||||
|
api_key_encrypted="encrypted_key_here",
|
||||||
|
)
|
||||||
|
db.add(creds)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, mp_store.id, "marketplace.connect_api"
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_import_products_incomplete_without_products(self, db, mp_store):
|
||||||
|
"""import_products step is incomplete when store has no products."""
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, mp_store.id, "marketplace.import_products"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_import_products_complete_with_products(self, db, mp_store):
|
||||||
|
"""import_products step is complete when store has at least one product."""
|
||||||
|
from app.modules.catalog.models.product import Product
|
||||||
|
|
||||||
|
product = Product(
|
||||||
|
store_id=mp_store.id,
|
||||||
|
store_sku=f"SKU-{uuid.uuid4().hex[:8]}",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, mp_store.id, "marketplace.import_products"
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_unknown_step_key_returns_false(self, db, mp_store):
|
||||||
|
"""Returns False for unrecognized step key."""
|
||||||
|
result = self.provider.is_step_completed(
|
||||||
|
db, mp_store.id, "marketplace.unknown"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
@@ -36,6 +36,15 @@ def _get_feature_provider():
|
|||||||
return tenancy_feature_provider
|
return tenancy_feature_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_onboarding_provider():
|
||||||
|
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||||
|
from app.modules.tenancy.services.tenancy_onboarding import (
|
||||||
|
tenancy_onboarding_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return tenancy_onboarding_provider
|
||||||
|
|
||||||
|
|
||||||
tenancy_module = ModuleDefinition(
|
tenancy_module = ModuleDefinition(
|
||||||
code="tenancy",
|
code="tenancy",
|
||||||
name="Tenancy Management",
|
name="Tenancy Management",
|
||||||
@@ -233,6 +242,7 @@ tenancy_module = ModuleDefinition(
|
|||||||
# Widget provider for dashboard widgets
|
# Widget provider for dashboard widgets
|
||||||
widget_provider=_get_widget_provider,
|
widget_provider=_get_widget_provider,
|
||||||
feature_provider=_get_feature_provider,
|
feature_provider=_get_feature_provider,
|
||||||
|
onboarding_provider=_get_onboarding_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["tenancy_module"]
|
__all__ = ["tenancy_module"]
|
||||||
|
|||||||
55
app/modules/tenancy/services/tenancy_onboarding.py
Normal file
55
app/modules/tenancy/services/tenancy_onboarding.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# app/modules/tenancy/services/tenancy_onboarding.py
|
||||||
|
"""
|
||||||
|
Onboarding provider for the tenancy module.
|
||||||
|
|
||||||
|
Provides the "Customize your store" onboarding step that is always present
|
||||||
|
for all platforms. Completed when the store has a description or a logo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.contracts.onboarding import OnboardingStepDefinition
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TenancyOnboardingProvider:
|
||||||
|
"""Onboarding provider for tenancy module (always present)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def onboarding_category(self) -> str:
|
||||||
|
return "tenancy"
|
||||||
|
|
||||||
|
def get_onboarding_steps(self) -> list[OnboardingStepDefinition]:
|
||||||
|
return [
|
||||||
|
OnboardingStepDefinition(
|
||||||
|
key="tenancy.customize_store",
|
||||||
|
title_key="onboarding.tenancy.customize_store.title",
|
||||||
|
description_key="onboarding.tenancy.customize_store.description",
|
||||||
|
icon="settings",
|
||||||
|
route_template="/store/{store_code}/settings",
|
||||||
|
order=100,
|
||||||
|
category="tenancy",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_step_completed(
|
||||||
|
self, db: Session, store_id: int, step_key: str
|
||||||
|
) -> bool:
|
||||||
|
if step_key != "tenancy.customize_store":
|
||||||
|
return False
|
||||||
|
|
||||||
|
from app.modules.tenancy.models.store import Store
|
||||||
|
|
||||||
|
store = db.query(Store).filter(Store.id == store_id).first()
|
||||||
|
if not store:
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_description = bool(store.description and store.description.strip())
|
||||||
|
has_logo = bool(store.get_logo_url())
|
||||||
|
return has_description or has_logo
|
||||||
|
|
||||||
|
|
||||||
|
tenancy_onboarding_provider = TenancyOnboardingProvider()
|
||||||
115
app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
Normal file
115
app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# app/modules/tenancy/tests/unit/test_tenancy_onboarding.py
|
||||||
|
"""Unit tests for TenancyOnboardingProvider."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.modules.tenancy.models import Merchant, Store, User
|
||||||
|
from app.modules.tenancy.services.tenancy_onboarding import TenancyOnboardingProvider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_owner(db):
|
||||||
|
"""Create a store owner for onboarding tests."""
|
||||||
|
from middleware.auth import AuthManager
|
||||||
|
|
||||||
|
auth = AuthManager()
|
||||||
|
user = User(
|
||||||
|
email=f"onbowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||||
|
username=f"onbowner_{uuid.uuid4().hex[:8]}",
|
||||||
|
hashed_password=auth.hash_password("pass123"),
|
||||||
|
role="merchant_owner",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_merchant(db, onb_owner):
|
||||||
|
"""Create a merchant for onboarding tests."""
|
||||||
|
merchant = Merchant(
|
||||||
|
name="Onboarding Test Merchant",
|
||||||
|
owner_user_id=onb_owner.id,
|
||||||
|
contact_email=onb_owner.email,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(merchant)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(merchant)
|
||||||
|
return merchant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def onb_store(db, onb_merchant):
|
||||||
|
"""Create a store with no description and no logo."""
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
store = Store(
|
||||||
|
merchant_id=onb_merchant.id,
|
||||||
|
store_code=f"ONBSTORE_{uid.upper()}",
|
||||||
|
subdomain=f"onbstore{uid.lower()}",
|
||||||
|
name="Onboarding Test Store",
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
)
|
||||||
|
db.add(store)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(store)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.tenancy
|
||||||
|
class TestTenancyOnboardingProvider:
|
||||||
|
"""Tests for TenancyOnboardingProvider."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.provider = TenancyOnboardingProvider()
|
||||||
|
|
||||||
|
def test_category(self):
|
||||||
|
"""Returns 'tenancy' as the onboarding category."""
|
||||||
|
assert self.provider.onboarding_category == "tenancy"
|
||||||
|
|
||||||
|
def test_get_onboarding_steps_returns_one_step(self):
|
||||||
|
"""Returns exactly one step: customize_store."""
|
||||||
|
steps = self.provider.get_onboarding_steps()
|
||||||
|
assert len(steps) == 1
|
||||||
|
assert steps[0].key == "tenancy.customize_store"
|
||||||
|
assert steps[0].route_template == "/store/{store_code}/settings"
|
||||||
|
assert steps[0].order == 100
|
||||||
|
|
||||||
|
def test_incomplete_when_no_description_no_logo(self, db, onb_store):
|
||||||
|
"""Step is not completed when store has no description and no logo."""
|
||||||
|
assert onb_store.description is None or onb_store.description.strip() == ""
|
||||||
|
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_completed_when_store_has_description(self, db, onb_store):
|
||||||
|
"""Step is completed when store has a non-empty description."""
|
||||||
|
onb_store.description = "We sell great coffee!"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_incomplete_when_description_is_whitespace(self, db, onb_store):
|
||||||
|
"""Step is not completed when description is whitespace only."""
|
||||||
|
onb_store.description = " "
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.customize_store")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_incomplete_for_nonexistent_store(self, db):
|
||||||
|
"""Returns False for a store ID that doesn't exist."""
|
||||||
|
result = self.provider.is_step_completed(db, 999999, "tenancy.customize_store")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_incomplete_for_unknown_step_key(self, db, onb_store):
|
||||||
|
"""Returns False for an unrecognized step key."""
|
||||||
|
result = self.provider.is_step_completed(db, onb_store.id, "tenancy.unknown_step")
|
||||||
|
assert result is False
|
||||||
@@ -337,6 +337,85 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{# =============================================================================
|
||||||
|
Onboarding Banner
|
||||||
|
Shows a checklist of post-signup onboarding steps from all enabled modules.
|
||||||
|
Steps are fetched from the API, dismiss is session-scoped (sessionStorage).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{{ onboarding_banner() }}
|
||||||
|
============================================================================= #}
|
||||||
|
{% macro onboarding_banner() %}
|
||||||
|
<div x-data="onboardingBanner()"
|
||||||
|
x-show="visible"
|
||||||
|
x-cloak
|
||||||
|
class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{# Header with progress #}
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span x-html="$icon('rocket', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="t('onboarding.banner.title')">Get Started</h3>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
x-text="`${completedSteps}/${totalSteps}`"></span>
|
||||||
|
</div>
|
||||||
|
<button @click="dismiss()"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
:title="t('onboarding.banner.dismiss')">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{# Progress bar #}
|
||||||
|
<div class="mt-3 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div class="bg-purple-600 h-2 rounded-full transition-all duration-500"
|
||||||
|
:style="`width: ${progressPercentage}%`"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Step checklist #}
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<template x-for="step in steps" :key="step.key">
|
||||||
|
<a :href="step.route"
|
||||||
|
class="flex items-center px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
|
||||||
|
{# Completion indicator #}
|
||||||
|
<div class="flex-shrink-0 mr-4">
|
||||||
|
<template x-if="step.completed">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!step.completed">
|
||||||
|
<div class="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600 group-hover:border-purple-400 transition-colors"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{# Step icon + text #}
|
||||||
|
<div class="flex-shrink-0 mr-3">
|
||||||
|
<span x-html="$icon(step.icon, 'w-5 h-5 text-gray-400 dark:text-gray-500')" class="block"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||||
|
:class="{ 'line-through text-gray-400 dark:text-gray-500': step.completed }"
|
||||||
|
x-text="t(step.title_key)"></p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"
|
||||||
|
x-text="t(step.description_key)"></p>
|
||||||
|
</div>
|
||||||
|
{# Arrow #}
|
||||||
|
<template x-if="!step.completed">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 group-hover:text-purple-500 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{# =============================================================================
|
{# =============================================================================
|
||||||
Email Settings Warning
|
Email Settings Warning
|
||||||
Shows warning banner when store email settings are not configured.
|
Shows warning banner when store email settings are not configured.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user