feat: loyalty feature provider, admin data fixes, storefront mobile menu
Some checks failed
CI / ruff (push) Successful in 9s
CI / pytest (push) Failing after 37m24s
CI / validate (push) Failing after 22s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add LoyaltyFeatureProvider with 11 BINARY/MERCHANT features for billing
  feature gating, wired into loyalty module definition
- Fix subscription-tiers admin page showing 0 features by populating
  feature_codes from tier relationship in all admin tier endpoints
- Fix merchants admin page showing 0 stores and N/A owner by adding
  store_count and owner_email to MerchantResponse and eager-loading owner
- Add "no tiers" warning with link in subscription creation modal when
  platform has no configured tiers
- Add missing mobile menu panel to storefront base template so hamburger
  toggle actually shows navigation links

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 18:59:24 +01:00
parent 2c710ad416
commit a8b29750a5
10 changed files with 350 additions and 13 deletions

View File

@@ -67,6 +67,7 @@ def list_subscription_tiers(
for t in tiers:
resp = SubscriptionTierResponse.model_validate(t)
resp.platform_name = platforms_map.get(t.platform_id) if t.platform_id else None
resp.feature_codes = sorted(t.get_feature_codes())
tiers_response.append(resp)
return SubscriptionTierListResponse(
@@ -83,7 +84,9 @@ def get_subscription_tier(
):
"""Get a specific subscription tier by code."""
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
return SubscriptionTierResponse.model_validate(tier)
resp = SubscriptionTierResponse.model_validate(tier)
resp.feature_codes = sorted(tier.get_feature_codes())
return resp
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
@@ -96,7 +99,9 @@ def create_subscription_tier(
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
db.commit()
db.refresh(tier)
return SubscriptionTierResponse.model_validate(tier)
resp = SubscriptionTierResponse.model_validate(tier)
resp.feature_codes = sorted(tier.get_feature_codes())
return resp
@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
@@ -111,7 +116,9 @@ def update_subscription_tier(
tier = admin_subscription_service.update_tier(db, tier_code, update_data)
db.commit()
db.refresh(tier)
return SubscriptionTierResponse.model_validate(tier)
resp = SubscriptionTierResponse.model_validate(tier)
resp.feature_codes = sorted(tier.get_feature_codes())
return resp
@admin_router.delete("/tiers/{tier_code}", status_code=204)

View File

@@ -44,6 +44,13 @@ def _get_storefront_router():
return storefront_router
def _get_feature_provider():
"""Lazy import of feature provider to avoid circular imports."""
from app.modules.loyalty.services.loyalty_features import loyalty_feature_provider
return loyalty_feature_provider
# Loyalty module definition
loyalty_module = ModuleDefinition(
code="loyalty",
@@ -213,6 +220,8 @@ loyalty_module = ModuleDefinition(
options={"queue": "scheduled"},
),
],
# Feature provider for billing feature gating
feature_provider=_get_feature_provider,
)

View File

@@ -0,0 +1,184 @@
# app/modules/loyalty/services/loyalty_features.py
"""
Loyalty feature provider for the billing feature system.
Declares loyalty-related billable features (stamps, points, cards, wallets, etc.)
for feature gating.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from app.modules.contracts.features import (
FeatureDeclaration,
FeatureScope,
FeatureType,
FeatureUsage,
)
if TYPE_CHECKING:
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
class LoyaltyFeatureProvider:
"""Feature provider for the loyalty module.
Declares:
- loyalty_stamps: stamp-based loyalty programs
- loyalty_points: points-based loyalty programs
- loyalty_hybrid: combined stamps and points
- loyalty_cards: customer card management
- loyalty_enrollment: customer enrollment
- loyalty_staff_pins: staff PIN management
- loyalty_anti_fraud: cooldown and daily limits
- loyalty_google_wallet: Google Wallet passes
- loyalty_apple_wallet: Apple Wallet passes
- loyalty_stats: dashboard statistics
- loyalty_reports: transaction reports
"""
@property
def feature_category(self) -> str:
return "loyalty"
def get_feature_declarations(self) -> list[FeatureDeclaration]:
return [
FeatureDeclaration(
code="loyalty_stamps",
name_key="loyalty.features.loyalty_stamps.name",
description_key="loyalty.features.loyalty_stamps.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="stamp",
display_order=10,
),
FeatureDeclaration(
code="loyalty_points",
name_key="loyalty.features.loyalty_points.name",
description_key="loyalty.features.loyalty_points.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="coins",
display_order=20,
),
FeatureDeclaration(
code="loyalty_hybrid",
name_key="loyalty.features.loyalty_hybrid.name",
description_key="loyalty.features.loyalty_hybrid.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="layers",
display_order=30,
),
FeatureDeclaration(
code="loyalty_cards",
name_key="loyalty.features.loyalty_cards.name",
description_key="loyalty.features.loyalty_cards.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="credit-card",
display_order=40,
),
FeatureDeclaration(
code="loyalty_enrollment",
name_key="loyalty.features.loyalty_enrollment.name",
description_key="loyalty.features.loyalty_enrollment.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="user-plus",
display_order=50,
),
FeatureDeclaration(
code="loyalty_staff_pins",
name_key="loyalty.features.loyalty_staff_pins.name",
description_key="loyalty.features.loyalty_staff_pins.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="shield",
display_order=60,
),
FeatureDeclaration(
code="loyalty_anti_fraud",
name_key="loyalty.features.loyalty_anti_fraud.name",
description_key="loyalty.features.loyalty_anti_fraud.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="lock",
display_order=70,
),
FeatureDeclaration(
code="loyalty_google_wallet",
name_key="loyalty.features.loyalty_google_wallet.name",
description_key="loyalty.features.loyalty_google_wallet.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="smartphone",
display_order=80,
),
FeatureDeclaration(
code="loyalty_apple_wallet",
name_key="loyalty.features.loyalty_apple_wallet.name",
description_key="loyalty.features.loyalty_apple_wallet.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="smartphone",
display_order=85,
),
FeatureDeclaration(
code="loyalty_stats",
name_key="loyalty.features.loyalty_stats.name",
description_key="loyalty.features.loyalty_stats.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="bar-chart",
display_order=90,
),
FeatureDeclaration(
code="loyalty_reports",
name_key="loyalty.features.loyalty_reports.name",
description_key="loyalty.features.loyalty_reports.description",
category="loyalty",
feature_type=FeatureType.BINARY,
scope=FeatureScope.MERCHANT,
ui_icon="file-text",
display_order=100,
),
]
def get_store_usage(
self,
db: Session,
store_id: int,
) -> list[FeatureUsage]:
return []
def get_merchant_usage(
self,
db: Session,
merchant_id: int,
platform_id: int,
) -> list[FeatureUsage]:
return []
# Singleton instance for module registration
loyalty_feature_provider = LoyaltyFeatureProvider()
__all__ = [
"LoyaltyFeatureProvider",
"loyalty_feature_provider",
]

View File

@@ -109,6 +109,7 @@ def get_all_merchants(
name=c.name,
description=c.description,
owner_user_id=c.owner_user_id,
owner_email=c.owner.email if c.owner else None,
contact_email=c.contact_email,
contact_phone=c.contact_phone,
website=c.website,
@@ -118,6 +119,7 @@ def get_all_merchants(
is_verified=c.is_verified,
created_at=c.created_at.isoformat(),
updated_at=c.updated_at.isoformat(),
store_count=c.store_count,
)
for c in merchants
],

View File

@@ -90,6 +90,7 @@ class MerchantResponse(BaseModel):
# Owner information
owner_user_id: int
owner_email: str | None = Field(None, description="Owner's email address")
# Contact Information
contact_email: str
@@ -108,6 +109,9 @@ class MerchantResponse(BaseModel):
created_at: str
updated_at: str
# Store statistics
store_count: int = Field(0, description="Number of stores under this merchant")
class MerchantDetailResponse(MerchantResponse):
"""
@@ -117,11 +121,9 @@ class MerchantDetailResponse(MerchantResponse):
"""
# Owner details (from related User)
owner_email: str | None = Field(None, description="Owner's email address")
owner_username: str | None = Field(None, description="Owner's username")
# Store statistics
store_count: int = Field(0, description="Number of stores under this merchant")
active_store_count: int = Field(
0, description="Number of active stores under this merchant"
)

View File

@@ -148,7 +148,10 @@ class MerchantService:
Returns:
Tuple of (merchants list, total count)
"""
query = select(Merchant).options(joinedload(Merchant.stores))
query = select(Merchant).options(
joinedload(Merchant.stores),
joinedload(Merchant.owner),
)
# Apply filters
if search:

View File

@@ -331,12 +331,20 @@
<!-- Tier Selector -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subscription Tier</label>
<select x-model="createForm.tier_code"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
<template x-for="tier in tiers" :key="tier.code">
<option :value="tier.code" x-text="tier.name + ' — ' + formatTierPrice(tier)"></option>
</template>
</select>
<template x-if="tiers.length > 0">
<select x-model="createForm.tier_code"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
<template x-for="tier in tiers" :key="tier.code">
<option :value="tier.code" x-text="tier.name + ' — ' + formatTierPrice(tier)"></option>
</template>
</select>
</template>
<template x-if="tiers.length === 0">
<div class="p-3 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg dark:text-amber-300 dark:bg-amber-900/20 dark:border-amber-800">
No tiers configured for this platform.
<a href="/admin/subscription-tiers" class="underline font-medium hover:text-amber-900 dark:hover:text-amber-100">Create a tier first</a>.
</div>
</template>
</div>
<!-- Status -->
@@ -372,7 +380,7 @@
Cancel
</button>
<button @click="createSubscription()"
:disabled="creatingSubscription"
:disabled="creatingSubscription || tiers.length === 0"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!creatingSubscription">Create</span>
<span x-show="creatingSubscription">Creating...</span>