feat: storefront subscription access guard + module-driven nav + URL rename

Add StorefrontAccessMiddleware that blocks storefront access for stores
without an active subscription, returning a multilingual unavailable page
(en/fr/de/lb) for page requests and JSON 403 for API requests. Multi-platform
aware: resolves subscription for detected platform with fallback to primary.

Also includes yesterday's session work:
- Module-driven storefront navigation via FrontendType.STOREFRONT menu declarations
- shop/ → storefront/ URL rename across 30+ templates
- Subscription context (tier_code) passed to storefront templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 13:27:31 +01:00
parent 682213fdee
commit 2c710ad416
46 changed files with 1484 additions and 231 deletions

View File

@@ -6,7 +6,13 @@ This module provides shopping cart functionality for customer storefronts.
It is session-based and does not require customer authentication.
"""
from app.modules.base import ModuleDefinition, PermissionDefinition
from app.modules.base import (
MenuItemDefinition,
MenuSectionDefinition,
ModuleDefinition,
PermissionDefinition,
)
from app.modules.enums import FrontendType
# =============================================================================
# Router Lazy Imports
@@ -53,7 +59,24 @@ cart_module = ModuleDefinition(
),
],
# Cart is storefront-only - no admin/store menus needed
menu_items={},
menus={
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="actions",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="cart",
label_key="storefront.actions.cart",
icon="shopping-cart",
route="storefront/cart",
order=20,
),
],
),
],
},
)

View File

@@ -13,7 +13,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Cart</span>
</div>
@@ -39,7 +39,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Add some products to get started!
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
Browse Products
</a>
</div>
@@ -55,8 +55,8 @@
{# Item Image #}
<div class="flex-shrink-0">
<img
:src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="item.name"
class="w-24 h-24 object-cover rounded-lg"
>
@@ -153,7 +153,7 @@
Proceed to Checkout
</button>
<a href="{{ base_url }}shop/products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<a href="{{ base_url }}storefront/products" class="block w-full px-6 py-3 text-center border-2 border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Continue Shopping
</a>
@@ -218,7 +218,7 @@ document.addEventListener('alpine:init', () => {
try {
console.log(`[SHOP] Loading cart for session ${this.sessionId}...`);
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
@@ -245,7 +245,7 @@ document.addEventListener('alpine:init', () => {
try {
console.log('[SHOP] Updating quantity:', productId, newQuantity);
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
{
method: 'PUT',
headers: {
@@ -280,7 +280,7 @@ document.addEventListener('alpine:init', () => {
try {
console.log('[SHOP] Removing item:', productId);
const response = await fetch(
`/api/v1/shop/cart/${this.sessionId}/items/${productId}`,
`/api/v1/storefront/cart/${this.sessionId}/items/${productId}`,
{
method: 'DELETE'
}
@@ -307,9 +307,9 @@ document.addEventListener('alpine:init', () => {
if (!token) {
// Redirect to login with return URL
window.location.href = '{{ base_url }}shop/account/login?return={{ base_url }}shop/checkout';
window.location.href = '{{ base_url }}storefront/account/login?return={{ base_url }}storefront/checkout';
} else {
window.location.href = '{{ base_url }}shop/checkout';
window.location.href = '{{ base_url }}storefront/checkout';
}
}
};

View File

@@ -125,6 +125,36 @@ catalog_module = ModuleDefinition(
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="nav",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="products",
label_key="storefront.nav.products",
icon="shopping-bag",
route="storefront/products",
order=10,
),
],
),
MenuSectionDefinition(
id="actions",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="search",
label_key="storefront.actions.search",
icon="search",
route="",
order=10,
),
],
),
],
},
# Metrics provider for dashboard statistics
metrics_provider=_get_metrics_provider,

View File

@@ -13,7 +13,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="categoryName">{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}</span>
</div>
@@ -61,14 +61,14 @@
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
<a :href="`{{ base_url }}storefront/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
@@ -99,7 +99,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-4">
Check back later or browse other categories.
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Browse All Products
</a>
</div>
@@ -213,9 +213,9 @@ document.addEventListener('alpine:init', () => {
params.append('sort', this.sortBy);
}
console.log(`[SHOP] Loading category products from /api/v1/shop/products?${params}`);
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
const response = await fetch(`/api/v1/shop/products?${params}`);
const response = await fetch(`/api/v1/storefront/products?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -246,7 +246,7 @@ document.addEventListener('alpine:init', () => {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1

View File

@@ -12,7 +12,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
</div>
@@ -29,8 +29,8 @@
<div class="product-images">
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
<img
:src="selectedImage || '/static/shop/img/placeholder.svg'"
@error="selectedImage = '/static/shop/img/placeholder.svg'"
:src="selectedImage || '/static/storefront/img/placeholder.svg'"
@error="selectedImage = '/static/storefront/img/placeholder.svg'"
:alt="product?.marketplace_product?.title"
class="w-full h-auto object-contain"
style="max-height: 600px;"
@@ -186,16 +186,16 @@
<div class="product-grid">
<template x-for="related in relatedProducts" :key="related.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<a :href="`{{ base_url }}storefront/products/${related.id}`">
<img
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
:src="related.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="related.marketplace_product?.title"
class="w-full h-48 object-cover"
>
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${related.id}`">
<a :href="`{{ base_url }}storefront/products/${related.id}`">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
</a>
<p class="text-2xl font-bold text-primary">
@@ -276,7 +276,7 @@ document.addEventListener('alpine:init', () => {
try {
console.log(`[SHOP] Loading product ${this.productId}...`);
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
if (!response.ok) {
throw new Error('Product not found');
@@ -301,7 +301,7 @@ document.addEventListener('alpine:init', () => {
this.showToast('Failed to load product', 'error');
// Redirect back to products after error
setTimeout(() => {
window.location.href = '{{ base_url }}shop/products';
window.location.href = '{{ base_url }}storefront/products';
}, 2000);
} finally {
this.loading = false;
@@ -311,7 +311,7 @@ document.addEventListener('alpine:init', () => {
// Load related products
async loadRelatedProducts() {
try {
const response = await fetch(`/api/v1/shop/products?limit=4`);
const response = await fetch(`/api/v1/storefront/products?limit=4`);
if (response.ok) {
const data = await response.json();
@@ -368,7 +368,7 @@ document.addEventListener('alpine:init', () => {
this.addingToCart = true;
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = {
product_id: parseInt(this.productId),
quantity: this.quantity

View File

@@ -73,14 +73,14 @@
<div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
<a :href="`{{ base_url }}storefront/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
@@ -178,9 +178,9 @@ document.addEventListener('alpine:init', () => {
params.append('search', this.filters.search);
}
console.log(`[SHOP] Loading products from /api/v1/shop/products?${params}`);
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
const response = await fetch(`/api/v1/shop/products?${params}`);
const response = await fetch(`/api/v1/storefront/products?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -211,7 +211,7 @@ document.addEventListener('alpine:init', () => {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1

View File

@@ -80,14 +80,14 @@
<div x-show="!loading && query && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<a :href="`{{ base_url }}shop/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
<a :href="`{{ base_url }}storefront/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
@@ -254,9 +254,9 @@ document.addEventListener('alpine:init', () => {
limit: this.perPage
});
console.log(`[SHOP] Searching: /api/v1/shop/products/search?${params}`);
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
const response = await fetch(`/api/v1/shop/products/search?${params}`);
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -292,7 +292,7 @@ document.addEventListener('alpine:init', () => {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1

View File

@@ -13,7 +13,7 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">Account</a>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">Account</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">Wishlist</span>
</div>
@@ -46,7 +46,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Log in to your account to view and manage your wishlist.
</p>
<a href="{{ base_url }}shop/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}storefront/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Log In
</a>
</div>
@@ -64,14 +64,14 @@
<span x-html="$icon('heart', 'w-5 h-5 text-red-500 fill-current')"></span>
</button>
<a :href="`{{ base_url }}shop/products/${item.product.id}`">
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
<a :href="`{{ base_url }}storefront/products/${item.product.id}`">
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="item.product.marketplace_product?.title"
class="w-full h-48 object-cover">
</a>
<div class="p-4">
<a :href="`{{ base_url }}shop/products/${item.product.id}`" class="block">
<a :href="`{{ base_url }}storefront/products/${item.product.id}`" class="block">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="item.product.marketplace_product?.title"></h3>
</a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="item.product.marketplace_product?.description"></p>
@@ -122,7 +122,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Save items you like by clicking the heart icon on product pages.
</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
@@ -157,7 +157,7 @@ document.addEventListener('alpine:init', () => {
async checkLoginStatus() {
try {
const response = await fetch('/api/v1/shop/customers/me');
const response = await fetch('/api/v1/storefront/customers/me');
return response.ok;
} catch (error) {
return false;
@@ -170,7 +170,7 @@ document.addEventListener('alpine:init', () => {
try {
console.log('[SHOP] Loading wishlist...');
const response = await fetch('/api/v1/shop/wishlist');
const response = await fetch('/api/v1/storefront/wishlist');
if (!response.ok) {
if (response.status === 401) {
@@ -197,7 +197,7 @@ document.addEventListener('alpine:init', () => {
try {
console.log('[SHOP] Removing from wishlist:', item);
const response = await fetch(`/api/v1/shop/wishlist/${item.id}`, {
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
method: 'DELETE'
});
@@ -217,7 +217,7 @@ document.addEventListener('alpine:init', () => {
console.log('[SHOP] Adding to cart:', product);
try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = {
product_id: product.id,
quantity: 1

View File

@@ -11,10 +11,10 @@
{# Breadcrumbs #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li><a href="{{ base_url }}shop/" class="hover:text-primary">Home</a></li>
<li><a href="{{ base_url }}storefront/" class="hover:text-primary">Home</a></li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</a>
<a href="{{ base_url }}storefront/cart" class="hover:text-primary">Cart</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -36,7 +36,7 @@
<span class="mx-auto h-12 w-12 text-gray-400 mb-4 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
@@ -384,8 +384,8 @@
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="item in cartItems" :key="item.product_id">
<div class="py-4 flex items-center gap-4">
<img loading="lazy" :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
<img loading="lazy" :src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
class="w-16 h-16 object-cover rounded-lg">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
@@ -428,8 +428,8 @@
<template x-for="item in cartItems" :key="item.product_id">
<div class="flex items-center gap-3">
<div class="relative">
<img loading="lazy" :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
<img loading="lazy" :src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/storefront/img/placeholder.svg'"
class="w-12 h-12 object-cover rounded">
<span class="absolute -top-2 -right-2 w-5 h-5 bg-gray-500 text-white text-xs rounded-full flex items-center justify-center" x-text="item.quantity"></span>
</div>
@@ -611,7 +611,7 @@ function checkoutPage() {
async loadCustomerData() {
try {
const response = await fetch('/api/v1/shop/auth/me');
const response = await fetch('/api/v1/storefront/auth/me');
if (response.ok) {
const data = await response.json();
this.isLoggedIn = true;
@@ -639,7 +639,7 @@ function checkoutPage() {
async loadSavedAddresses() {
try {
const response = await fetch('/api/v1/shop/addresses');
const response = await fetch('/api/v1/storefront/addresses');
if (response.ok) {
const data = await response.json();
this.savedAddresses = data.addresses || [];
@@ -706,7 +706,7 @@ function checkoutPage() {
async loadCart() {
this.loading = true;
try {
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.cartItems = data.items || [];
@@ -790,7 +790,7 @@ function checkoutPage() {
country_iso: this.shippingAddress.country_iso,
is_default: this.shippingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
const response = await fetch('/api/v1/storefront/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
@@ -820,7 +820,7 @@ function checkoutPage() {
country_iso: this.billingAddress.country_iso,
is_default: this.billingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
const response = await fetch('/api/v1/storefront/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
@@ -883,7 +883,7 @@ function checkoutPage() {
console.log('[CHECKOUT] Placing order:', orderData);
const response = await fetch('/api/v1/shop/orders', {
const response = await fetch('/api/v1/storefront/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -900,7 +900,7 @@ function checkoutPage() {
console.log('[CHECKOUT] Order placed:', order.order_number);
// Redirect to confirmation page
window.location.href = '{{ base_url }}shop/order-confirmation?order=' + order.order_number;
window.location.href = '{{ base_url }}storefront/order-confirmation?order=' + order.order_number;
} catch (error) {
console.error('[CHECKOUT] Error placing order:', error);

View File

@@ -172,7 +172,7 @@
</template>
<!-- Preview button -->
<a
:href="`/stores/${storeCode}/shop/${page.slug}`"
:href="`/stores/${storeCode}/storefront/${page.slug}`"
target="_blank"
class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
title="Preview"
@@ -278,7 +278,7 @@
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<a
:href="`/stores/${storeCode}/shop/${page.slug}`"
:href="`/stores/${storeCode}/storefront/${page.slug}`"
target="_blank"
class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
title="Preview"

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-default.html #}
{# standalone #}
{# Default/Minimal Landing Page Template #}
{% extends "shop/base.html" %}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -36,7 +36,7 @@
{# CTA Button #}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
@@ -71,7 +71,7 @@
Explore
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}shop/products"
<a href="{{ base_url }}storefront/products"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -84,7 +84,7 @@
{% if header_pages %}
{% for page in header_pages[:2] %}
<a href="{{ base_url }}shop/{{ page.slug }}"
<a href="{{ base_url }}storefront/{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -96,7 +96,7 @@
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}shop/about"
<a href="{{ base_url }}storefront/about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -107,7 +107,7 @@
</p>
</a>
<a href="{{ base_url }}shop/contact"
<a href="{{ base_url }}storefront/contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-full.html #}
{# standalone #}
{# Full Landing Page Template - Maximum Features #}
{% extends "shop/base.html" %}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -43,7 +43,7 @@
{% endif %}
<div class="flex flex-col sm:flex-row gap-4">
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg"
style="background-color: var(--color-primary)">
Shop Now
@@ -174,7 +174,7 @@
Explore More
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}shop/products"
<a href="{{ base_url }}storefront/products"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">🛍️</div>
@@ -190,7 +190,7 @@
{% if header_pages %}
{% for page in header_pages[:2] %}
<a href="{{ base_url }}shop/{{ page.slug }}"
<a href="{{ base_url }}storefront/{{ page.slug }}"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 dark:from-accent/20 dark:to-accent/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">📄</div>
@@ -205,7 +205,7 @@
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}shop/about"
<a href="{{ base_url }}storefront/about"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/10 to-accent/5 dark:from-accent/20 dark:to-accent/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4"></div>
@@ -219,7 +219,7 @@
<div class="absolute inset-0 bg-accent opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a>
<a href="{{ base_url }}shop/contact"
<a href="{{ base_url }}storefront/contact"
class="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 p-8 hover:shadow-xl transition-all">
<div class="relative z-10">
<div class="text-5xl mb-4">📧</div>
@@ -246,7 +246,7 @@
<p class="text-xl mb-10 opacity-90">
Join thousands of satisfied customers today
</p>
<a href="{{ base_url }}shop/products"
<a href="{{ base_url }}storefront/products"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-primary bg-white hover:bg-gray-50 transition-all transform hover:scale-105 shadow-2xl">
View All Products
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-minimal.html #}
{# standalone #}
{# Minimal Landing Page Template - Ultra Clean #}
{% extends "shop/base.html" %}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -37,7 +37,7 @@
{# Single CTA #}
<div>
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center justify-center px-10 py-5 text-xl font-semibold rounded-full text-white bg-primary hover:bg-primary-dark transition-all transform hover:scale-105 shadow-2xl"
style="background-color: var(--color-primary)">
Enter Shop
@@ -49,11 +49,11 @@
{% if header_pages or footer_pages %}
<div class="mt-16 pt-8 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-wrap justify-center gap-6 text-sm">
<a href="{{ base_url }}shop/products" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
<a href="{{ base_url }}storefront/products" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
Products
</a>
{% for page in (header_pages or footer_pages)[:4] %}
<a href="{{ base_url }}shop/{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
<a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
{{ page.title }}
</a>
{% endfor %}

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-modern.html #}
{# standalone #}
{# Modern Landing Page Template - Feature Rich #}
{% extends "shop/base.html" %}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -40,7 +40,7 @@
{# CTAs #}
<div class="flex flex-col sm:flex-row gap-6 justify-center animate-fade-in animation-delay-400">
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="group inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-white bg-primary hover:bg-primary-dark transition-all transform hover:scale-105 shadow-2xl hover:shadow-3xl"
style="background-color: var(--color-primary)">
<span>Start Shopping</span>
@@ -137,7 +137,7 @@
<p class="text-xl mb-10 opacity-90">
Explore our collection and find what you're looking for
</p>
<a href="{{ base_url }}shop/products"
<a href="{{ base_url }}storefront/products"
class="inline-flex items-center justify-center px-10 py-5 text-lg font-bold rounded-xl text-primary bg-white hover:bg-gray-50 transition-all transform hover:scale-105 shadow-2xl">
Browse Products
<span class="w-5 h-5 ml-2" x-html="$icon('chevron-right', 'w-5 h-5')"></span>

View File

@@ -146,6 +146,29 @@ def get_context_for_frontend(
f"[CONTEXT] {len(modules_with_providers)} modules have providers but none contributed"
)
# Pass enabled module codes to templates for conditional rendering
context["enabled_modules"] = enabled_module_codes
# For storefront, build nav menu structure from module declarations
if frontend_type == FrontendType.STOREFRONT:
from app.modules.core.services.menu_discovery_service import (
menu_discovery_service,
)
platform_id = platform.id if platform else None
sections = menu_discovery_service.get_menu_sections_for_frontend(
db, FrontendType.STOREFRONT, platform_id
)
# Build dict of section_id -> list of enabled items for easy template access
storefront_nav: dict[str, list] = {}
for section in sections:
enabled_items = [
item for item in section.items if item.is_module_enabled
]
if enabled_items:
storefront_nav[section.id] = enabled_items
context["storefront_nav"] = storefront_nav
# Add any extra context passed by the caller
if extra_context:
context.update(extra_context)
@@ -318,6 +341,10 @@ def get_storefront_context(
)
base_url = f"{full_prefix}{store.subdomain}/"
# Read subscription info set by StorefrontAccessMiddleware
subscription = getattr(request.state, "subscription", None)
subscription_tier = getattr(request.state, "subscription_tier", None)
# Build storefront-specific base context
storefront_base = {
"store": store,
@@ -325,6 +352,11 @@ def get_storefront_context(
"clean_path": clean_path,
"access_method": access_method,
"base_url": base_url,
"enabled_modules": set(),
"storefront_nav": {},
"subscription": subscription,
"subscription_tier": subscription_tier,
"tier_code": subscription_tier.code if subscription_tier else None,
}
# If no db session, return just the base context

View File

@@ -132,6 +132,43 @@ customers_module = ModuleDefinition(
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="account",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="dashboard",
label_key="storefront.account.dashboard",
icon="home",
route="storefront/account/dashboard",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="storefront.account.profile",
icon="user",
route="storefront/account/profile",
order=20,
),
MenuItemDefinition(
id="addresses",
label_key="storefront.account.addresses",
icon="map-pin",
route="storefront/account/addresses",
order=30,
),
MenuItemDefinition(
id="settings",
label_key="storefront.account.settings",
icon="cog",
route="storefront/account/settings",
order=90,
),
],
),
],
},
is_core=True, # Customers is a core module - customer data is fundamental
# =========================================================================

View File

@@ -392,7 +392,7 @@ function addressesPage() {
try {
const token = localStorage.getItem('customer_token');
const response = await fetch('/api/v1/shop/addresses', {
const response = await fetch('/api/v1/storefront/addresses', {
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
@@ -400,7 +400,7 @@ function addressesPage() {
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
return;
}
throw new Error('Failed to load addresses');
@@ -461,8 +461,8 @@ function addressesPage() {
try {
const token = localStorage.getItem('customer_token');
const url = this.editingAddress
? `/api/v1/shop/addresses/${this.editingAddress.id}`
: '/api/v1/shop/addresses';
? `/api/v1/storefront/addresses/${this.editingAddress.id}`
: '/api/v1/storefront/addresses';
const method = this.editingAddress ? 'PUT' : 'POST';
const response = await fetch(url, {
@@ -500,7 +500,7 @@ function addressesPage() {
try {
const token = localStorage.getItem('customer_token');
const response = await fetch(`/api/v1/shop/addresses/${this.deletingAddressId}`, {
const response = await fetch(`/api/v1/storefront/addresses/${this.deletingAddressId}`, {
method: 'DELETE',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
@@ -525,7 +525,7 @@ function addressesPage() {
async setAsDefault(addressId) {
try {
const token = localStorage.getItem('customer_token');
const response = await fetch(`/api/v1/shop/addresses/${addressId}/default`, {
const response = await fetch(`/api/v1/storefront/addresses/${addressId}/default`, {
method: 'PUT',
headers: {
'Authorization': token ? `Bearer ${token}` : '',

View File

@@ -18,7 +18,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card -->
<a href="{{ base_url }}shop/account/orders"
<a href="{{ base_url }}storefront/account/orders"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
@@ -36,7 +36,7 @@
</a>
<!-- Profile Card -->
<a href="{{ base_url }}shop/account/profile"
<a href="{{ base_url }}storefront/account/profile"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
@@ -53,7 +53,7 @@
</a>
<!-- Addresses Card -->
<a href="{{ base_url }}shop/account/addresses"
<a href="{{ base_url }}storefront/account/addresses"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
@@ -67,10 +67,10 @@
</a>
<!-- Messages Card -->
<a href="{{ base_url }}shop/account/messages"
<a href="{{ base_url }}storefront/account/messages"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ unreadCount: 0 }"
x-init="fetch('/api/v1/shop/messages/unread-count').then(r => r.json()).then(d => unreadCount = d.unread_count).catch(() => {})">
x-init="fetch('/api/v1/storefront/messages/unread-count').then(r => r.json()).then(d => unreadCount = d.unread_count).catch(() => {})">
<div class="flex items-center mb-4">
<div class="flex-shrink-0 relative">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('chat-bubble-left', 'h-8 w-8')"></span>
@@ -141,7 +141,7 @@ function accountDashboard() {
// Close modal
this.showLogoutModal = false;
fetch('/api/v1/shop/auth/logout', {
fetch('/api/v1/storefront/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -157,14 +157,14 @@ function accountDashboard() {
// Redirect to login page
setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
}, 500);
} else {
console.error('Logout failed with status:', response.status);
this.showToast('Logout failed', 'error');
// Still redirect on failure (cookie might be deleted)
setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
}, 1000);
}
})
@@ -173,7 +173,7 @@ function accountDashboard() {
this.showToast('Logout failed', 'error');
// Redirect anyway
setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
}, 1000);
});
}

View File

@@ -39,7 +39,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -143,13 +143,13 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}shop/account/login">
href="{{ base_url }}storefront/account/login">
Sign in
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}shop/">
href="{{ base_url }}storefront/">
← Continue shopping
</a>
</p>
@@ -218,7 +218,7 @@
this.loading = true;
try {
const response = await fetch('/api/v1/shop/auth/forgot-password', {
const response = await fetch('/api/v1/storefront/auth/forgot-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'

View File

@@ -38,7 +38,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -127,7 +127,7 @@
style="color: var(--color-primary);">
<span class="ml-2 text-gray-700 dark:text-gray-400">Remember me</span>
</label>
<a href="{{ base_url }}shop/account/forgot-password"
<a href="{{ base_url }}storefront/account/forgot-password"
class="text-sm font-medium hover:underline"
style="color: var(--color-primary);">
Forgot password?
@@ -150,13 +150,13 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Don't have an account?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}shop/account/register">
href="{{ base_url }}storefront/account/register">
Create an account
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}shop/">
href="{{ base_url }}storefront/">
← Continue shopping
</a>
</p>
@@ -238,7 +238,7 @@
this.loading = true;
try {
const response = await fetch('/api/v1/shop/auth/login', {
const response = await fetch('/api/v1/storefront/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -263,7 +263,7 @@
// Redirect to account page or return URL
setTimeout(() => {
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}shop/account';
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}storefront/account';
window.location.href = returnUrl;
}, 1000);

View File

@@ -11,7 +11,7 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -330,11 +330,11 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch('/api/v1/shop/profile', {
const response = await fetch('/api/v1/storefront/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -344,7 +344,7 @@ function shopProfilePage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load profile');
@@ -380,11 +380,11 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
return;
}
const response = await fetch('/api/v1/shop/profile', {
const response = await fetch('/api/v1/storefront/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
@@ -429,11 +429,11 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
return;
}
const response = await fetch('/api/v1/shop/profile', {
const response = await fetch('/api/v1/storefront/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
@@ -472,11 +472,11 @@ function shopProfilePage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login';
window.location.href = '{{ base_url }}storefront/account/login';
return;
}
const response = await fetch('/api/v1/shop/profile/password', {
const response = await fetch('/api/v1/storefront/profile/password', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,

View File

@@ -39,7 +39,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -218,7 +218,7 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Already have an account?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}shop/account/login">
href="{{ base_url }}storefront/account/login">
Sign in instead
</a>
</p>
@@ -341,7 +341,7 @@
this.loading = true;
try {
const response = await fetch('/api/v1/shop/auth/register', {
const response = await fetch('/api/v1/storefront/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -360,7 +360,7 @@
// Redirect to login after 2 seconds
setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login?registered=true';
window.location.href = '{{ base_url }}storefront/account/login?registered=true';
}, 2000);
} catch (error) {

View File

@@ -39,7 +39,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -80,7 +80,7 @@
Please request a new password reset link.
</p>
<a href="{{ base_url }}shop/account/forgot-password"
<a href="{{ base_url }}storefront/account/forgot-password"
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
Request New Link
</a>
@@ -164,7 +164,7 @@
You can now sign in with your new password.
</p>
<a href="{{ base_url }}shop/account/login"
<a href="{{ base_url }}storefront/account/login"
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
Sign In
</a>
@@ -177,13 +177,13 @@
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}shop/account/login">
href="{{ base_url }}storefront/account/login">
Sign in
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}shop/">
href="{{ base_url }}storefront/">
← Continue shopping
</a>
</p>
@@ -273,7 +273,7 @@
this.loading = true;
try {
const response = await fetch('/api/v1/shop/auth/reset-password', {
const response = await fetch('/api/v1/storefront/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'

View File

@@ -168,6 +168,22 @@ loyalty_module = ModuleDefinition(
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="account",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="loyalty",
label_key="storefront.account.loyalty",
icon="gift",
route="storefront/account/loyalty",
order=60,
),
],
),
],
},
is_core=False, # Loyalty can be disabled
# =========================================================================

View File

@@ -9,7 +9,7 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<a href="{{ base_url }}shop/account/dashboard" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<a href="{{ base_url }}storefront/account/dashboard" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Account
</a>
@@ -26,7 +26,7 @@
<span x-html="$icon('gift', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Join Our Rewards Program!</h2>
<p class="mt-2 text-gray-600 dark:text-gray-400">Earn points on every purchase and redeem for rewards.</p>
<a href="{{ base_url }}shop/loyalty/join"
<a href="{{ base_url }}storefront/loyalty/join"
class="mt-6 inline-flex items-center px-6 py-3 text-sm font-medium text-white rounded-lg"
style="background-color: var(--color-primary)">
<span x-html="$icon('plus', 'w-5 h-5 mr-2')"></span>
@@ -125,7 +125,7 @@
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Activity</h2>
<a href="{{ base_url }}shop/account/loyalty/history"
<a href="{{ base_url }}storefront/account/loyalty/history"
class="text-sm font-medium hover:underline" style="color: var(--color-primary)">
View All
</a>

View File

@@ -62,12 +62,12 @@
<!-- Actions -->
<div class="space-y-3">
<a href="{{ base_url }}shop/account/loyalty"
<a href="{{ base_url }}storefront/account/loyalty"
class="block w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors text-center"
style="background-color: var(--color-primary)">
View My Loyalty Dashboard
</a>
<a href="{{ base_url }}shop"
<a href="{{ base_url }}storefront"
class="block w-full py-3 px-4 text-gray-700 dark:text-gray-300 font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-center">
Continue Shopping
</a>

View File

@@ -173,6 +173,22 @@ messaging_module = ModuleDefinition(
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="account",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="messages",
label_key="storefront.account.messages",
icon="chat-bubble-left-right",
route="storefront/account/messages",
order=50,
),
],
),
],
},
is_core=True, # Core module - email/notifications required for registration, password reset, etc.
# =========================================================================

View File

@@ -13,7 +13,7 @@
<nav class="flex mb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="{{ base_url }}shop/account/dashboard"
<a href="{{ base_url }}storefront/account/dashboard"
class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary dark:text-gray-400 dark:hover:text-white"
style="--hover-color: var(--color-primary)">
<span class="w-4 h-4 mr-2" x-html="$icon('home', 'w-4 h-4')"></span>
@@ -292,7 +292,7 @@ function shopMessages() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -304,7 +304,7 @@ function shopMessages() {
params.append('status', this.statusFilter);
}
const response = await fetch(`/api/v1/shop/messages?${params}`, {
const response = await fetch(`/api/v1/storefront/messages?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -313,7 +313,7 @@ function shopMessages() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load conversations');
@@ -335,11 +335,11 @@ function shopMessages() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/messages/${conversationId}`, {
const response = await fetch(`/api/v1/storefront/messages/${conversationId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -357,7 +357,7 @@ function shopMessages() {
});
// Update URL without reload
const url = `{{ base_url }}shop/account/messages/${conversationId}`;
const url = `{{ base_url }}storefront/account/messages/${conversationId}`;
history.pushState({}, '', url);
} catch (error) {
console.error('Error loading conversation:', error);
@@ -372,7 +372,7 @@ function shopMessages() {
const token = localStorage.getItem('customer_token');
if (!token) return;
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`, {
const response = await fetch(`/api/v1/storefront/messages/${this.selectedConversation.id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -404,7 +404,7 @@ function shopMessages() {
this.loadConversations();
// Update URL
history.pushState({}, '', '{{ base_url }}shop/account/messages');
history.pushState({}, '', '{{ base_url }}storefront/account/messages');
},
async sendReply() {
@@ -415,7 +415,7 @@ function shopMessages() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
@@ -425,7 +425,7 @@ function shopMessages() {
formData.append('attachments', file);
}
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}/messages`, {
const response = await fetch(`/api/v1/storefront/messages/${this.selectedConversation.id}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`

View File

@@ -137,6 +137,22 @@ orders_module = ModuleDefinition(
],
),
],
FrontendType.STOREFRONT: [
MenuSectionDefinition(
id="account",
label_key=None,
order=10,
items=[
MenuItemDefinition(
id="orders",
label_key="storefront.account.orders",
icon="clipboard-list",
route="storefront/account/orders",
order=40,
),
],
),
],
},
is_core=False,
# =========================================================================

View File

@@ -11,11 +11,11 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<a href="{{ base_url }}shop/account/orders" class="hover:text-primary">Orders</a>
<a href="{{ base_url }}storefront/account/orders" class="hover:text-primary">Orders</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -36,7 +36,7 @@
<div class="ml-3">
<h3 class="text-lg font-medium text-red-800 dark:text-red-200">Error loading order</h3>
<p class="mt-1 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
<a href="{{ base_url }}shop/account/orders"
<a href="{{ base_url }}storefront/account/orders"
class="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700">
Back to Orders
</a>
@@ -314,7 +314,7 @@
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
If you have any questions about your order, please contact us.
</p>
<a href="{{ base_url }}shop/account/messages"
<a href="{{ base_url }}storefront/account/messages"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
style="background-color: var(--color-primary)">
<span class="h-4 w-4 mr-2" x-html="$icon('chat-bubble-left', 'h-4 w-4')"></span>
@@ -326,7 +326,7 @@
<!-- Back Button -->
<div class="mt-8">
<a href="{{ base_url }}shop/account/orders"
<a href="{{ base_url }}storefront/account/orders"
class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-primary">
<span class="h-4 w-4 mr-2" x-html="$icon('chevron-left', 'h-4 w-4')"></span>
Back to Orders
@@ -375,11 +375,11 @@ function shopOrderDetailPage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`, {
const response = await fetch(`/api/v1/storefront/orders/${this.orderId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -389,7 +389,7 @@ function shopOrderDetailPage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (response.status === 404) {
@@ -501,11 +501,11 @@ function shopOrderDetailPage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/orders/${this.orderId}/invoice`, {
const response = await fetch(`/api/v1/storefront/orders/${this.orderId}/invoice`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
@@ -516,7 +516,7 @@ function shopOrderDetailPage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (response.status === 404) {

View File

@@ -11,7 +11,7 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -45,7 +45,7 @@
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('shopping-bag', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No orders yet</h3>
<p class="mt-2 text-gray-500 dark:text-gray-400">Start shopping to see your orders here.</p>
<a href="{{ base_url }}shop/products"
<a href="{{ base_url }}storefront/products"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark transition-colors"
style="background-color: var(--color-primary)">
Browse Products
@@ -79,7 +79,7 @@
<span x-text="getStatusLabel(order.status)"></span>
</span>
<!-- View Details Button -->
<a :href="'{{ base_url }}shop/account/orders/' + order.id"
<a :href="'{{ base_url }}storefront/account/orders/' + order.id"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
View Details
<span class="ml-2 h-4 w-4" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
@@ -167,12 +167,12 @@ function shopOrdersPage() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const skip = (page - 1) * this.perPage;
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`, {
const response = await fetch(`/api/v1/storefront/orders?skip=${skip}&limit=${this.perPage}`, {
headers: {
'Authorization': `Bearer ${token}`
}
@@ -182,7 +182,7 @@ function shopOrdersPage() {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
window.location.href = '{{ base_url }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load orders');

View File

@@ -63,7 +63,7 @@
{# Store Logo #}
<div class="flex items-center">
<a href="{{ base_url }}shop/" class="flex items-center space-x-3">
<a href="{{ base_url }}storefront/" class="flex items-center space-x-3">
{% if theme.branding.logo %}
{# Show light logo in light mode, dark logo in dark mode #}
<img x-show="!dark"
@@ -84,25 +84,28 @@
</a>
</div>
{# Navigation #}
{# Navigation — Home is always shown, module items are dynamic #}
<nav class="hidden md:flex space-x-8">
<a href="{{ base_url }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
Home
</a>
<a href="{{ base_url }}shop/products" class="text-gray-700 dark:text-gray-300 hover:text-primary">
Products
{% for item in storefront_nav.get('nav', []) %}
<a href="{{ base_url }}{{ item.route }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
{{ _(item.label_key) }}
</a>
<a href="{{ base_url }}shop/about" class="text-gray-700 dark:text-gray-300 hover:text-primary">
About
</a>
<a href="{{ base_url }}shop/contact" class="text-gray-700 dark:text-gray-300 hover:text-primary">
Contact
{% endfor %}
{# CMS pages (About, Contact) are already dynamic via header_pages #}
{% for page in header_pages|default([]) %}
<a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
{{ page.title }}
</a>
{% endfor %}
</nav>
{# Right side actions #}
<div class="flex items-center space-x-4">
{% if 'catalog' in enabled_modules|default([]) %}
{# Search #}
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -110,9 +113,11 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
{% endif %}
{% if 'cart' in enabled_modules|default([]) %}
{# Cart #}
<a href="{{ base_url }}shop/cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<a href="{{ base_url }}storefront/cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
@@ -123,6 +128,7 @@
style="background-color: var(--color-accent)">
</span>
</a>
{% endif %}
{# Theme toggle #}
<button @click="toggleTheme()"
@@ -181,7 +187,7 @@
{% endif %}
{# Account #}
<a href="{{ base_url }}shop/account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<a href="{{ base_url }}storefront/account" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
@@ -256,9 +262,11 @@
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2">
<li><a href="{{ base_url }}shop/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %}
{% for page in col1_pages %}
<li><a href="{{ base_url }}shop/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
<li><a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
@@ -269,7 +277,7 @@
<h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2">
{% for page in col2_pages %}
<li><a href="{{ base_url }}shop/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
<li><a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-600 hover:text-primary dark:text-gray-400">{{ page.title }}</a></li>
{% endfor %}
</ul>
</div>
@@ -279,18 +287,20 @@
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2">
<li><a href="{{ base_url }}shop/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
<li><a href="{{ base_url }}shop/about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
<li><a href="{{ base_url }}shop/contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
{% if 'catalog' in enabled_modules|default([]) %}
<li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
{% endif %}
<li><a href="{{ base_url }}storefront/about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li>
<li><a href="{{ base_url }}storefront/contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2">
<li><a href="{{ base_url }}shop/faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
<li><a href="{{ base_url }}shop/shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
<li><a href="{{ base_url }}shop/returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
<li><a href="{{ base_url }}storefront/faq" class="text-gray-600 hover:text-primary dark:text-gray-400">FAQ</a></li>
<li><a href="{{ base_url }}storefront/shipping" class="text-gray-600 hover:text-primary dark:text-gray-400">Shipping</a></li>
<li><a href="{{ base_url }}storefront/returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
</ul>
</div>
{% endif %}
@@ -321,7 +331,7 @@
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
{# 4. Base Shop Layout (Alpine.js component - must load before Alpine) #}
<script defer src="{{ url_for('static', path='storefront/js/storefront-layout.js') }}"></script>
<script defer src="{{ url_for('core_static', path='storefront/js/storefront-layout.js') }}"></script>
{# 5. Utilities #}
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go Back
</a>
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
Need help? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}401 - Authentication Required{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}shop/account/login"
<a href="{{ base_url }}storefront/account/login"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Log In
</a>
<a href="{{ base_url }}shop/account/register"
<a href="{{ base_url }}storefront/account/register"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Create Account
</a>
{% endblock %}
{% block support_link %}
Don't have an account? <a href="{{ base_url }}shop/account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
Don't have an account? <a href="{{ base_url }}storefront/account/register" class="text-theme-primary font-semibold hover:underline">Sign up now</a>
{% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}403 - Access Restricted{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}shop/account/login"
<a href="{{ base_url }}storefront/account/login"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Log In
</a>
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Need help accessing your account? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
Need help accessing your account? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact support</a>
{% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}404 - Page Not Found{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
<a href="{{ base_url }}shop/products"
<a href="{{ base_url }}storefront/products"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
View All Products
</a>
{% endblock %}
{% block support_link %}
Can't find what you're looking for? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it.
{% endblock %}
Can't find what you're looking for? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a> and we'll help you find it.
{% endblock %}

View File

@@ -24,12 +24,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go Back and Fix
</a>
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Having trouble? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
Having trouble? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">We're here to help</a>
{% endblock %}

View File

@@ -21,12 +21,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Try Again
</a>
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
Questions? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
Questions? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -7,7 +7,7 @@
{% block title %}500 - Something Went Wrong{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Go to Home
</a>
@@ -18,5 +18,5 @@
{% endblock %}
{% block support_link %}
Issue persisting? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out.
Issue persisting? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Let us know</a> and we'll help you out.
{% endblock %}

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Try Again
</a>
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Go to Home
</a>
{% endblock %}
{% block support_link %}
If this continues, <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
If this continues, <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">let us know</a>
{% endblock %}

View File

@@ -8,7 +8,7 @@
<title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if store %} | {{ store.name }}{% endif %}</title>
{# Tailwind CSS #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
{# Store theme colors via CSS variables #}
<style>
@@ -76,11 +76,11 @@
{# Action Buttons #}
<div class="flex gap-4 justify-center flex-wrap mt-8">
{% block action_buttons %}
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
<a href="{{ base_url }}shop/contact"
<a href="{{ base_url }}storefront/contact"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Contact Us
</a>
@@ -92,7 +92,7 @@
{# Support Link #}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
{% block support_link %}
Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
Need help? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
{% endblock %}
</div>

View File

@@ -7,7 +7,7 @@
{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}
{% block action_buttons %}
<a href="{{ base_url }}shop/"
<a href="{{ base_url }}storefront/"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
@@ -18,5 +18,5 @@
{% endblock %}
{% block support_link %}
Need assistance? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
Need assistance? <a href="{{ base_url }}storefront/contact" class="text-theme-primary font-semibold hover:underline">Contact us</a>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{# app/templates/storefront/unavailable.html — standalone, no base.html extends #}
{# Rendered by StorefrontAccessMiddleware when store has no active subscription #}
<!DOCTYPE html>
<html lang="{{ language }}" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}{% if store %} | {{ store.name }}{% endif %}</title>
{# Tailwind CSS #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
{# Theme colors (use store theme if available, fallback to purple gradient) #}
<style>
:root {
--color-primary: {{ theme.colors.primary if theme and theme.colors else '#6366f1' }};
--color-secondary: {{ theme.colors.secondary if theme and theme.colors else '#8b5cf6' }};
--color-accent: {{ theme.colors.accent if theme and theme.colors else '#ec4899' }};
}
.bg-gradient-theme {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
}
.text-theme-primary {
color: var(--color-primary);
}
.bg-theme-primary {
background-color: var(--color-primary);
}
</style>
</head>
<body class="h-full bg-gradient-theme flex items-center justify-center p-8">
<div class="bg-white rounded-3xl shadow-2xl max-w-xl w-full p-12 text-center">
{# Store logo if available #}
{% if store and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}"
alt="{{ store.name }}"
class="max-w-[150px] max-h-[60px] mx-auto mb-8 object-contain">
{% endif %}
{# Icon #}
<div class="text-7xl mb-6">
{% if reason == 'not_found' %}
<svg class="w-20 h-20 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{% else %}
<svg class="w-20 h-20 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{% endif %}
</div>
{# Title #}
<h1 class="text-3xl font-semibold text-gray-900 mb-4">
{{ title }}
</h1>
{# Message #}
<p class="text-lg text-gray-500 mb-10 leading-relaxed">
{{ message }}
</p>
{# Action button #}
<div class="flex gap-4 justify-center flex-wrap">
<a href="javascript:history.back()"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
{% if language == 'fr' %}Retour
{% elif language == 'de' %}Zur&uuml;ck
{% elif language == 'lb' %}Zr&eacute;ck
{% else %}Go Back
{% endif %}
</a>
</div>
{# Store name footer #}
{% if store %}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-400">
{{ store.name }}
</div>
{% endif %}
</div>
</body>
</html>

34
main.py
View File

@@ -76,6 +76,7 @@ from middleware.logging import LoggingMiddleware
# Import REFACTORED class-based middleware
from middleware.platform_context import PlatformContextMiddleware
from middleware.store_context import StoreContextMiddleware
from middleware.storefront_access import StorefrontAccessMiddleware
from middleware.theme_context import ThemeContextMiddleware
logger = logging.getLogger(__name__)
@@ -120,20 +121,22 @@ app.add_middleware(
# So we add them in REVERSE order of desired execution:
#
# Desired execution order:
# 0. ProxyHeadersMiddleware (trust X-Forwarded-Proto from Caddy)
# 1. PlatformContextMiddleware (detect platform from domain/path)
# 2. StoreContextMiddleware (detect store, uses platform_clean_path)
# 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector)
# 4. LanguageMiddleware (detect language based on frontend type)
# 5. ThemeContextMiddleware (load theme)
# 6. LoggingMiddleware (log all requests)
# 0. ProxyHeadersMiddleware (trust X-Forwarded-Proto from Caddy)
# 1. PlatformContextMiddleware (detect platform from domain/path)
# 2. StoreContextMiddleware (detect store, uses platform_clean_path)
# 3. FrontendTypeMiddleware (detect frontend type using FrontendDetector)
# 4. LanguageMiddleware (detect language based on frontend type)
# 5. StorefrontAccessMiddleware (block unsubscribed storefronts)
# 6. ThemeContextMiddleware (load theme)
# 7. LoggingMiddleware (log all requests)
#
# Therefore we add them in REVERSE:
# - Add ThemeContextMiddleware FIRST (runs LAST in request)
# - Add LanguageMiddleware SECOND
# - Add FrontendTypeMiddleware THIRD
# - Add StoreContextMiddleware FOURTH
# - Add PlatformContextMiddleware FIFTH
# - Add StorefrontAccessMiddleware SECOND (runs after Language)
# - Add LanguageMiddleware THIRD
# - Add FrontendTypeMiddleware FOURTH
# - Add StoreContextMiddleware FIFTH
# - Add PlatformContextMiddleware SIXTH
# - Add LoggingMiddleware LAST (runs FIRST for timing)
# ============================================================================
@@ -149,6 +152,10 @@ app.add_middleware(LoggingMiddleware)
logger.info("Adding ThemeContextMiddleware (detects and loads theme)")
app.add_middleware(ThemeContextMiddleware)
# Add storefront access guard (blocks unsubscribed storefronts)
logger.info("Adding StorefrontAccessMiddleware (subscription gate for storefronts)")
app.add_middleware(StorefrontAccessMiddleware)
# Add language middleware (detects language after context is determined)
logger.info("Adding LanguageMiddleware (detects language based on context)")
app.add_middleware(LanguageMiddleware)
@@ -180,8 +187,9 @@ logger.info(" 2. PlatformContextMiddleware (platform detection)")
logger.info(" 3. StoreContextMiddleware (store detection)")
logger.info(" 4. FrontendTypeMiddleware (frontend type detection)")
logger.info(" 5. LanguageMiddleware (language detection)")
logger.info(" 6. ThemeContextMiddleware (theme loading)")
logger.info(" 7. FastAPI Router")
logger.info(" 6. StorefrontAccessMiddleware (subscription gate)")
logger.info(" 7. ThemeContextMiddleware (theme loading)")
logger.info(" 8. FastAPI Router")
logger.info("=" * 80)
# ========================================

View File

@@ -0,0 +1,189 @@
# middleware/storefront_access.py
"""
Storefront subscription access guard.
Blocks storefront access for stores without an active subscription.
Inserted in the middleware chain AFTER LanguageMiddleware, BEFORE ThemeContextMiddleware,
so request.state has: platform, store, frontend_type, language.
For page requests: renders a standalone "unavailable" HTML page.
For API requests: returns JSON 403.
Multi-platform aware: checks subscription for the detected platform first,
falls back to the store's primary platform subscription.
"""
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.core.database import get_db
from app.modules.enums import FrontendType
logger = logging.getLogger(__name__)
# Paths that should never be blocked (health, static, etc.)
SKIP_PATH_PREFIXES = ("/static/", "/uploads/", "/health", "/docs", "/redoc", "/openapi.json")
STATIC_EXTENSIONS = (
".ico", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg",
".woff", ".woff2", ".ttf", ".eot", ".webp", ".map",
)
# Multilingual messages (en, fr, de, lb)
MESSAGES = {
"not_found": {
"en": {
"title": "Storefront Not Found",
"message": "This storefront does not exist or has been removed.",
},
"fr": {
"title": "Boutique introuvable",
"message": "Cette boutique n'existe pas ou a \u00e9t\u00e9 supprim\u00e9e.",
},
"de": {
"title": "Shop nicht gefunden",
"message": "Dieser Shop existiert nicht oder wurde entfernt.",
},
"lb": {
"title": "Buttek net fonnt",
"message": "D\u00ebse Buttek exist\u00e9iert net oder gouf ewechgeholl.",
},
},
"not_activated": {
"en": {
"title": "Storefront Not Activated",
"message": "This storefront is not yet activated. Please contact the store owner.",
},
"fr": {
"title": "Boutique non activ\u00e9e",
"message": "Cette boutique n'est pas encore activ\u00e9e. Veuillez contacter le propri\u00e9taire.",
},
"de": {
"title": "Shop nicht aktiviert",
"message": "Dieser Shop ist noch nicht aktiviert. Bitte kontaktieren Sie den Inhaber.",
},
"lb": {
"title": "Buttek net aktiv\u00e9iert",
"message": "D\u00ebse Buttek ass nach net aktiv\u00e9iert. Kontakt\u00e9iert w.e.g. den Bes\u00ebtzer.",
},
},
}
def _is_static_request(path: str) -> bool:
"""Check if path targets a static resource."""
lower = path.lower()
if any(lower.startswith(p) for p in SKIP_PATH_PREFIXES):
return True
if lower.endswith(STATIC_EXTENSIONS):
return True
return "favicon.ico" in lower
class StorefrontAccessMiddleware(BaseHTTPMiddleware):
"""
Gate storefront requests behind an active subscription.
Execution position (request flow):
... -> LanguageMiddleware -> **StorefrontAccessMiddleware** -> ThemeContextMiddleware -> Router
"""
async def dispatch(self, request: Request, call_next) -> Response:
frontend_type = getattr(request.state, "frontend_type", None)
# Only gate storefront requests
if frontend_type != FrontendType.STOREFRONT:
return await call_next(request)
# Skip static files
if _is_static_request(request.url.path):
return await call_next(request)
store = getattr(request.state, "store", None)
# Case 1: No store detected at all
if not store:
return self._render_unavailable(request, "not_found")
# Case 2: Store exists — check subscription
db = next(get_db())
try:
subscription = self._get_subscription(db, store, request)
if not subscription or not subscription.is_active:
logger.info(
f"[STOREFRONT_ACCESS] Blocked store '{store.subdomain}' "
f"(merchant_id={store.merchant_id}): no active subscription"
)
return self._render_unavailable(request, "not_activated", store)
# Store subscription info for downstream use
request.state.subscription = subscription
request.state.subscription_tier = subscription.tier
finally:
db.close()
return await call_next(request)
def _get_subscription(self, db, store, request):
"""Resolve subscription, handling multi-platform stores correctly."""
from app.modules.billing.services.subscription_service import (
subscription_service,
)
platform = getattr(request.state, "platform", None)
# If we have a detected platform, check subscription for THAT platform
if platform:
sub = subscription_service.get_merchant_subscription(
db, store.merchant_id, platform.id
)
if sub:
return sub
# Fallback: use store's primary platform (via StorePlatform)
return subscription_service.get_subscription_for_store(db, store.id)
def _render_unavailable(
self, request: Request, reason: str, store=None
) -> Response:
"""Return an appropriate response for blocked requests."""
is_api = request.url.path.startswith("/api/")
if is_api:
return JSONResponse(
status_code=403,
content={
"error": "storefront_not_available",
"reason": reason,
},
)
# Page request — render HTML
language = getattr(request.state, "language", "en")
if language not in MESSAGES[reason]:
language = "en"
msgs = MESSAGES[reason][language]
theme = getattr(request.state, "theme", None)
from app.templates_config import templates
context = {
"request": request,
"reason": reason,
"title": msgs["title"],
"message": msgs["message"],
"language": language,
"store": store,
"theme": theme,
}
return templates.TemplateResponse(
"storefront/unavailable.html",
context,
status_code=403,
)

View File

@@ -0,0 +1,787 @@
# tests/unit/middleware/test_storefront_access.py
"""
Unit tests for StorefrontAccessMiddleware.
Tests cover:
- Passthrough for non-storefront frontend types (ADMIN, STORE, PLATFORM, MERCHANT)
- Passthrough for static file requests
- Blocking when no store is detected (not_found)
- Blocking when store has no subscription (not_activated)
- Blocking when subscription is inactive (not_activated)
- Passthrough when subscription is active (TRIAL, ACTIVE, PAST_DUE, CANCELLED)
- Multi-platform subscription resolution (platform-specific, fallback)
- API requests return JSON 403
- Page requests return HTML 403
- Language detection for unavailable page
- request.state.subscription and subscription_tier are set on passthrough
"""
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from fastapi import Request
from starlette.responses import JSONResponse
from app.modules.enums import FrontendType
from middleware.storefront_access import (
MESSAGES,
StorefrontAccessMiddleware,
_is_static_request,
)
# =============================================================================
# Helper: build a mock Request with the right state attributes
# =============================================================================
def _make_request(
path="/storefront/products",
frontend_type=FrontendType.STOREFRONT,
store=None,
platform=None,
language="en",
theme=None,
):
"""Create a mock Request with pre-set state attributes."""
request = Mock(spec=Request)
request.url = Mock(path=path)
request.state = Mock()
request.state.frontend_type = frontend_type
request.state.store = store
request.state.platform = platform
request.state.language = language
request.state.theme = theme
return request
def _make_store(store_id=1, subdomain="testshop", merchant_id=10):
"""Create a mock Store object."""
store = Mock()
store.id = store_id
store.subdomain = subdomain
store.merchant_id = merchant_id
store.name = "Test Shop"
return store
def _make_platform(platform_id=1):
"""Create a mock Platform object."""
platform = Mock()
platform.id = platform_id
platform.code = "oms"
return platform
def _make_subscription(is_active=True, tier_code="essential"):
"""Create a mock MerchantSubscription."""
tier = Mock()
tier.code = tier_code
tier.id = 1
sub = Mock()
sub.is_active = is_active
sub.tier = tier
sub.status = "active" if is_active else "expired"
return sub
# =============================================================================
# Static request detection
# =============================================================================
@pytest.mark.unit
class TestIsStaticRequest:
"""Test suite for _is_static_request helper."""
@pytest.mark.parametrize(
"path",
[
"/static/css/style.css",
"/static/js/app.js",
"/uploads/images/photo.jpg",
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/storefront/favicon.ico",
"/some/path/favicon.ico",
"/static/storefront/css/tailwind.output.css",
],
)
def test_static_paths_detected(self, path):
"""Test that static/system paths are correctly detected."""
assert _is_static_request(path) is True
@pytest.mark.parametrize(
"path",
[
"/storefront/products",
"/storefront/",
"/api/v1/storefront/cart",
"/storefront/category/shoes",
],
)
def test_non_static_paths_not_detected(self, path):
"""Test that real storefront paths are not flagged as static."""
assert _is_static_request(path) is False
@pytest.mark.parametrize(
"path",
[
"/storefront/logo.png",
"/some/path/font.woff2",
"/assets/icon.svg",
"/file.map",
],
)
def test_static_extensions_detected(self, path):
"""Test that paths ending with static extensions are detected."""
assert _is_static_request(path) is True
def test_case_insensitive(self):
"""Test detection is case-insensitive."""
assert _is_static_request("/STATIC/CSS/STYLE.CSS") is True
assert _is_static_request("/Uploads/Image.PNG") is True
# =============================================================================
# Middleware passthrough tests (non-storefront)
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewarePassthrough:
"""Test that non-storefront requests pass through without checks."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"frontend_type",
[
FrontendType.ADMIN,
FrontendType.STORE,
FrontendType.PLATFORM,
FrontendType.MERCHANT,
],
)
async def test_non_storefront_passes_through(self, frontend_type):
"""Test non-storefront frontend types are not gated."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(frontend_type=frontend_type)
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_no_frontend_type_passes_through(self):
"""Test request with no frontend_type set passes through."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request()
request.state.frontend_type = None
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_static_file_passes_through(self):
"""Test storefront static file requests pass through."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/static/css/style.css")
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_favicon_passes_through(self):
"""Test favicon requests pass through."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/favicon.ico")
call_next = AsyncMock(return_value=Mock())
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# =============================================================================
# Blocking tests (no store / no subscription)
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewareBlocking:
"""Test that requests are blocked when store/subscription is missing."""
@pytest.mark.asyncio
async def test_no_store_returns_not_found(self):
"""Test 'not_found' when no store is detected."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(store=None)
call_next = AsyncMock()
with patch.object(middleware, "_render_unavailable") as mock_render:
mock_render.return_value = Mock()
await middleware.dispatch(request, call_next)
mock_render.assert_called_once_with(request, "not_found")
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_no_subscription_returns_not_activated(self):
"""Test 'not_activated' when store exists but no subscription."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware,
"_get_subscription",
return_value=None,
),
):
with patch.object(middleware, "_render_unavailable") as mock_render:
mock_render.return_value = Mock()
await middleware.dispatch(request, call_next)
mock_render.assert_called_once_with(
request, "not_activated", store
)
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_inactive_subscription_returns_not_activated(self):
"""Test 'not_activated' when subscription exists but is inactive."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock()
mock_db = MagicMock()
inactive_sub = _make_subscription(is_active=False)
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware,
"_get_subscription",
return_value=inactive_sub,
),
):
with patch.object(middleware, "_render_unavailable") as mock_render:
mock_render.return_value = Mock()
await middleware.dispatch(request, call_next)
mock_render.assert_called_once_with(
request, "not_activated", store
)
call_next.assert_not_called()
@pytest.mark.asyncio
async def test_db_session_closed_on_block(self):
"""Test database session is closed even when request is blocked."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(store=store)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(middleware, "_get_subscription", return_value=None),
patch.object(
middleware, "_render_unavailable", return_value=Mock()
),
):
await middleware.dispatch(request, call_next)
mock_db.close.assert_called_once()
# =============================================================================
# Active subscription passthrough
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewareActiveSubscription:
"""Test passthrough and state injection for active subscriptions."""
@pytest.mark.asyncio
async def test_active_subscription_passes_through(self):
"""Test active subscription lets request through."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock(return_value=Mock())
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True, tier_code="professional")
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware, "_get_subscription", return_value=active_sub
),
):
await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_sets_subscription_on_request_state(self):
"""Test subscription and tier are stored on request.state."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(store=store, platform=platform)
call_next = AsyncMock(return_value=Mock())
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True, tier_code="essential")
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware, "_get_subscription", return_value=active_sub
),
):
await middleware.dispatch(request, call_next)
assert request.state.subscription is active_sub
assert request.state.subscription_tier is active_sub.tier
@pytest.mark.asyncio
async def test_db_session_closed_on_success(self):
"""Test database session is closed after successful passthrough."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(store=store)
call_next = AsyncMock(return_value=Mock())
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True)
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware, "_get_subscription", return_value=active_sub
),
):
await middleware.dispatch(request, call_next)
mock_db.close.assert_called_once()
# =============================================================================
# Multi-platform subscription resolution
# =============================================================================
@pytest.mark.unit
class TestGetSubscription:
"""Test _get_subscription multi-platform resolution logic."""
def test_uses_detected_platform(self):
"""Test subscription is fetched for the detected platform."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(store=store, platform=platform)
mock_db = MagicMock()
expected_sub = _make_subscription()
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_merchant_subscription.return_value = expected_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_called_once_with(
mock_db, 10, 2
)
assert result is expected_sub
def test_falls_back_to_store_primary_platform(self):
"""Test fallback to get_subscription_for_store when platform sub is None."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=5, merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(store=store, platform=platform)
mock_db = MagicMock()
fallback_sub = _make_subscription(tier_code="starter")
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_called_once_with(
mock_db, 10, 2
)
mock_svc.get_subscription_for_store.assert_called_once_with(
mock_db, 5
)
assert result is fallback_sub
def test_no_platform_uses_store_fallback(self):
"""Test when no platform is detected, falls back to store-based lookup."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(store_id=7)
request = _make_request(store=store, platform=None)
mock_db = MagicMock()
fallback_sub = _make_subscription()
with patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc:
mock_svc.get_subscription_for_store.return_value = fallback_sub
result = middleware._get_subscription(mock_db, store, request)
mock_svc.get_merchant_subscription.assert_not_called()
mock_svc.get_subscription_for_store.assert_called_once_with(
mock_db, 7
)
assert result is fallback_sub
# =============================================================================
# Response rendering tests
# =============================================================================
@pytest.mark.unit
class TestRenderUnavailable:
"""Test _render_unavailable response generation."""
def test_api_request_returns_json_403(self):
"""Test API requests get JSON 403 response."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/api/v1/storefront/cart")
response = middleware._render_unavailable(request, "not_activated")
assert isinstance(response, JSONResponse)
assert response.status_code == 403
assert response.body is not None
# Decode JSON body
import json
body = json.loads(response.body)
assert body["error"] == "storefront_not_available"
assert body["reason"] == "not_activated"
def test_api_not_found_returns_json_403(self):
"""Test API not_found also gets JSON 403."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/api/v1/storefront/products")
response = middleware._render_unavailable(request, "not_found")
assert isinstance(response, JSONResponse)
assert response.status_code == 403
import json
body = json.loads(response.body)
assert body["reason"] == "not_found"
def test_page_request_renders_template(self):
"""Test page requests render the unavailable template."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/products", language="en")
mock_template_response = Mock(status_code=403)
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = mock_template_response
middleware._render_unavailable(
request, "not_activated", store=_make_store()
)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "storefront/unavailable.html"
context = call_args[0][1]
assert context["request"] is request
assert context["reason"] == "not_activated"
assert context["title"] == MESSAGES["not_activated"]["en"]["title"]
assert context["message"] == MESSAGES["not_activated"]["en"]["message"]
assert context["language"] == "en"
assert call_args[1]["status_code"] == 403
@pytest.mark.parametrize("language", ["en", "fr", "de", "lb"])
def test_page_request_uses_correct_language(self, language):
"""Test unavailable page renders in the detected language."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(
path="/storefront/", language=language
)
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_found")
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["language"] == language
assert context["title"] == MESSAGES["not_found"][language]["title"]
def test_unsupported_language_falls_back_to_english(self):
"""Test unsupported language falls back to English."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(
path="/storefront/", language="pt"
)
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_activated")
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["language"] == "en"
assert context["title"] == MESSAGES["not_activated"]["en"]["title"]
def test_page_request_includes_store_when_provided(self):
"""Test store object is passed to template when available."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(path="/storefront/")
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_activated", store=store)
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["store"] is store
def test_page_request_store_none_for_not_found(self):
"""Test store is None for not_found case."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/")
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
middleware._render_unavailable(request, "not_found")
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["store"] is None
# =============================================================================
# Messages dict validation
# =============================================================================
@pytest.mark.unit
class TestMessages:
"""Validate the MESSAGES dict structure."""
def test_all_reasons_have_all_languages(self):
"""Test every reason has en, fr, de, lb translations."""
for reason in ("not_found", "not_activated"):
assert reason in MESSAGES
for lang in ("en", "fr", "de", "lb"):
assert lang in MESSAGES[reason], f"Missing {lang} for {reason}"
assert "title" in MESSAGES[reason][lang]
assert "message" in MESSAGES[reason][lang]
def test_messages_are_non_empty_strings(self):
"""Test all message values are non-empty strings."""
for reason in MESSAGES:
for lang in MESSAGES[reason]:
for field in ("title", "message"):
value = MESSAGES[reason][lang][field]
assert isinstance(value, str)
assert len(value) > 0
# =============================================================================
# Full integration-style dispatch tests
# =============================================================================
@pytest.mark.unit
class TestStorefrontAccessMiddlewareDispatchIntegration:
"""End-to-end dispatch tests exercising the full middleware flow."""
@pytest.mark.asyncio
async def test_full_flow_active_subscription(self):
"""Test full dispatch: store + active subscription → passthrough."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store(merchant_id=10)
platform = _make_platform(platform_id=2)
request = _make_request(
path="/storefront/products",
store=store,
platform=platform,
)
expected_response = Mock()
call_next = AsyncMock(return_value=expected_response)
mock_db = MagicMock()
active_sub = _make_subscription(is_active=True, tier_code="professional")
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc,
):
mock_svc.get_merchant_subscription.return_value = active_sub
result = await middleware.dispatch(request, call_next)
assert result is expected_response
assert request.state.subscription is active_sub
assert request.state.subscription_tier is active_sub.tier
call_next.assert_called_once_with(request)
mock_db.close.assert_called_once()
@pytest.mark.asyncio
async def test_full_flow_no_subscription_page_request(self):
"""Test full dispatch: store + no subscription + page → HTML 403."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(
path="/storefront/",
store=store,
platform=platform,
language="fr",
)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc,
patch("app.templates_config.templates") as mock_templates,
):
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = None
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
await middleware.dispatch(request, call_next)
call_next.assert_not_called()
mock_templates.TemplateResponse.assert_called_once()
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["language"] == "fr"
assert context["reason"] == "not_activated"
mock_db.close.assert_called_once()
@pytest.mark.asyncio
async def test_full_flow_no_subscription_api_request(self):
"""Test full dispatch: store + no subscription + API → JSON 403."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
platform = _make_platform()
request = _make_request(
path="/api/v1/storefront/cart",
store=store,
platform=platform,
)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch(
"app.modules.billing.services.subscription_service.subscription_service"
) as mock_svc,
):
mock_svc.get_merchant_subscription.return_value = None
mock_svc.get_subscription_for_store.return_value = None
result = await middleware.dispatch(request, call_next)
call_next.assert_not_called()
assert isinstance(result, JSONResponse)
assert result.status_code == 403
mock_db.close.assert_called_once()
@pytest.mark.asyncio
async def test_full_flow_no_store_detected(self):
"""Test full dispatch: no store → not_found response."""
middleware = StorefrontAccessMiddleware(app=None)
request = _make_request(path="/storefront/", store=None)
call_next = AsyncMock()
with patch("app.templates_config.templates") as mock_templates:
mock_templates.TemplateResponse.return_value = Mock(status_code=403)
await middleware.dispatch(request, call_next)
call_next.assert_not_called()
context = mock_templates.TemplateResponse.call_args[0][1]
assert context["reason"] == "not_found"
assert context["store"] is None
@pytest.mark.asyncio
async def test_db_closed_on_exception(self):
"""Test database session is closed even when _get_subscription raises."""
middleware = StorefrontAccessMiddleware(app=None)
store = _make_store()
request = _make_request(store=store)
call_next = AsyncMock()
mock_db = MagicMock()
with (
patch(
"middleware.storefront_access.get_db",
return_value=iter([mock_db]),
),
patch.object(
middleware,
"_get_subscription",
side_effect=Exception("db error"),
),
pytest.raises(Exception, match="db error"),
):
await middleware.dispatch(request, call_next)
mock_db.close.assert_called_once()