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:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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}` : '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
# =========================================================================
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
# =========================================================================
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
89
app/templates/storefront/unavailable.html
Normal file
89
app/templates/storefront/unavailable.html
Normal 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ück
|
||||
{% elif language == 'lb' %}Zré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
34
main.py
@@ -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)
|
||||
|
||||
# ========================================
|
||||
|
||||
189
middleware/storefront_access.py
Normal file
189
middleware/storefront_access.py
Normal 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,
|
||||
)
|
||||
787
tests/unit/middleware/test_storefront_access.py
Normal file
787
tests/unit/middleware/test_storefront_access.py
Normal 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()
|
||||
Reference in New Issue
Block a user