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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
<div class="breadcrumb mb-6"> <div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a> <a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span> <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>/</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> <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> </div>
@@ -61,14 +61,14 @@
<div x-show="!loading && products.length > 0" class="product-grid"> <div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id"> <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"> <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}`"> <a :href="`{{ base_url }}storefront/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'" <img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'" @error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title" :alt="product.marketplace_product?.title"
class="w-full h-48 object-cover"> class="w-full h-48 object-cover">
</a> </a>
<div class="p-4"> <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> <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> </a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p> <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"> <p class="text-gray-600 dark:text-gray-400 mb-4">
Check back later or browse other categories. Check back later or browse other categories.
</p> </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 Browse All Products
</a> </a>
</div> </div>
@@ -213,9 +213,9 @@ document.addEventListener('alpine:init', () => {
params.append('sort', this.sortBy); 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) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -246,7 +246,7 @@ document.addEventListener('alpine:init', () => {
console.log('[SHOP] Adding to cart:', product); console.log('[SHOP] Adding to cart:', product);
try { try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`; const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = { const payload = {
product_id: product.id, product_id: product.id,
quantity: 1 quantity: 1

View File

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

View File

@@ -73,14 +73,14 @@
<div x-show="!loading && products.length > 0" class="product-grid"> <div x-show="!loading && products.length > 0" class="product-grid">
<template x-for="product in products" :key="product.id"> <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"> <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}`"> <a :href="`{{ base_url }}storefront/products/${product.id}`">
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'" <img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'" @error="$el.src = '/static/storefront/img/placeholder.svg'"
:alt="product.marketplace_product?.title" :alt="product.marketplace_product?.title"
class="w-full h-48 object-cover"> class="w-full h-48 object-cover">
</a> </a>
<div class="p-4"> <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> <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> </a>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p> <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); 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) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -211,7 +211,7 @@ document.addEventListener('alpine:init', () => {
console.log('[SHOP] Adding to cart:', product); console.log('[SHOP] Adding to cart:', product);
try { try {
const url = `/api/v1/shop/cart/${this.sessionId}/items`; const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
const payload = { const payload = {
product_id: product.id, product_id: product.id,
quantity: 1 quantity: 1

View File

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

View File

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

View File

@@ -11,10 +11,10 @@
{# Breadcrumbs #} {# Breadcrumbs #}
<nav class="mb-6" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"> <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"> <li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span> <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>
<li class="flex items-center"> <li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span> <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> <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> <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> <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 Browse Products
</a> </a>
</div> </div>
@@ -384,8 +384,8 @@
<div class="divide-y divide-gray-200 dark:divide-gray-700"> <div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="item in cartItems" :key="item.product_id"> <template x-for="item in cartItems" :key="item.product_id">
<div class="py-4 flex items-center gap-4"> <div class="py-4 flex items-center gap-4">
<img loading="lazy" :src="item.image_url || '/static/shop/img/placeholder.svg'" <img loading="lazy" :src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'" @error="$el.src = '/static/storefront/img/placeholder.svg'"
class="w-16 h-16 object-cover rounded-lg"> class="w-16 h-16 object-cover rounded-lg">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p> <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"> <template x-for="item in cartItems" :key="item.product_id">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative"> <div class="relative">
<img loading="lazy" :src="item.image_url || '/static/shop/img/placeholder.svg'" <img loading="lazy" :src="item.image_url || '/static/storefront/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'" @error="$el.src = '/static/storefront/img/placeholder.svg'"
class="w-12 h-12 object-cover rounded"> 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> <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> </div>
@@ -611,7 +611,7 @@ function checkoutPage() {
async loadCustomerData() { async loadCustomerData() {
try { try {
const response = await fetch('/api/v1/shop/auth/me'); const response = await fetch('/api/v1/storefront/auth/me');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
this.isLoggedIn = true; this.isLoggedIn = true;
@@ -639,7 +639,7 @@ function checkoutPage() {
async loadSavedAddresses() { async loadSavedAddresses() {
try { try {
const response = await fetch('/api/v1/shop/addresses'); const response = await fetch('/api/v1/storefront/addresses');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
this.savedAddresses = data.addresses || []; this.savedAddresses = data.addresses || [];
@@ -706,7 +706,7 @@ function checkoutPage() {
async loadCart() { async loadCart() {
this.loading = true; this.loading = true;
try { try {
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`); const response = await fetch(`/api/v1/storefront/cart/${this.sessionId}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
this.cartItems = data.items || []; this.cartItems = data.items || [];
@@ -790,7 +790,7 @@ function checkoutPage() {
country_iso: this.shippingAddress.country_iso, country_iso: this.shippingAddress.country_iso,
is_default: this.shippingAddresses.length === 0 // Make default if first address 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData) body: JSON.stringify(addressData)
@@ -820,7 +820,7 @@ function checkoutPage() {
country_iso: this.billingAddress.country_iso, country_iso: this.billingAddress.country_iso,
is_default: this.billingAddresses.length === 0 // Make default if first address 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData) body: JSON.stringify(addressData)
@@ -883,7 +883,7 @@ function checkoutPage() {
console.log('[CHECKOUT] Placing order:', orderData); console.log('[CHECKOUT] Placing order:', orderData);
const response = await fetch('/api/v1/shop/orders', { const response = await fetch('/api/v1/storefront/orders', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -900,7 +900,7 @@ function checkoutPage() {
console.log('[CHECKOUT] Order placed:', order.order_number); console.log('[CHECKOUT] Order placed:', order.order_number);
// Redirect to confirmation page // 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) { } catch (error) {
console.error('[CHECKOUT] Error placing order:', error); console.error('[CHECKOUT] Error placing order:', error);

View File

@@ -172,7 +172,7 @@
</template> </template>
<!-- Preview button --> <!-- Preview button -->
<a <a
:href="`/stores/${storeCode}/shop/${page.slug}`" :href="`/stores/${storeCode}/storefront/${page.slug}`"
target="_blank" 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" 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" title="Preview"
@@ -278,7 +278,7 @@
<span x-html="$icon('edit', 'w-5 h-5')"></span> <span x-html="$icon('edit', 'w-5 h-5')"></span>
</a> </a>
<a <a
:href="`/stores/${storeCode}/shop/${page.slug}`" :href="`/stores/${storeCode}/storefront/${page.slug}`"
target="_blank" 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" 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" title="Preview"

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-default.html #} {# app/templates/store/landing-default.html #}
{# standalone #} {# standalone #}
{# Default/Minimal Landing Page Template #} {# Default/Minimal Landing Page Template #}
{% extends "shop/base.html" %} {% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %} {% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %} {% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -36,7 +36,7 @@
{# CTA Button #} {# CTA Button #}
<div class="flex flex-col sm:flex-row gap-4 justify-center"> <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" 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)"> style="background-color: var(--color-primary)">
Browse Our Shop Browse Our Shop
@@ -71,7 +71,7 @@
Explore Explore
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <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"> 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> <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"> <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 %} {% if header_pages %}
{% for page in header_pages[:2] %} {% 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"> 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> <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"> <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -96,7 +96,7 @@
</a> </a>
{% endfor %} {% endfor %}
{% else %} {% 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"> 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> <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"> <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
@@ -107,7 +107,7 @@
</p> </p>
</a> </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"> 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> <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"> <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-full.html #} {# app/templates/store/landing-full.html #}
{# standalone #} {# standalone #}
{# Full Landing Page Template - Maximum Features #} {# Full Landing Page Template - Maximum Features #}
{% extends "shop/base.html" %} {% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %} {% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %} {% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -43,7 +43,7 @@
{% endif %} {% endif %}
<div class="flex flex-col sm:flex-row gap-4"> <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" 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)"> style="background-color: var(--color-primary)">
Shop Now Shop Now
@@ -174,7 +174,7 @@
Explore More Explore More
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <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"> 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="relative z-10">
<div class="text-5xl mb-4">🛍️</div> <div class="text-5xl mb-4">🛍️</div>
@@ -190,7 +190,7 @@
{% if header_pages %} {% if header_pages %}
{% for page in header_pages[:2] %} {% 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"> 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="relative z-10">
<div class="text-5xl mb-4">📄</div> <div class="text-5xl mb-4">📄</div>
@@ -205,7 +205,7 @@
</a> </a>
{% endfor %} {% endfor %}
{% else %} {% 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"> 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="relative z-10">
<div class="text-5xl mb-4"></div> <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> <div class="absolute inset-0 bg-accent opacity-0 group-hover:opacity-5 transition-opacity"></div>
</a> </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"> 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="relative z-10">
<div class="text-5xl mb-4">📧</div> <div class="text-5xl mb-4">📧</div>
@@ -246,7 +246,7 @@
<p class="text-xl mb-10 opacity-90"> <p class="text-xl mb-10 opacity-90">
Join thousands of satisfied customers today Join thousands of satisfied customers today
</p> </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"> 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 View All Products
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span> <span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-minimal.html #} {# app/templates/store/landing-minimal.html #}
{# standalone #} {# standalone #}
{# Minimal Landing Page Template - Ultra Clean #} {# Minimal Landing Page Template - Ultra Clean #}
{% extends "shop/base.html" %} {% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %} {% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %} {% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -37,7 +37,7 @@
{# Single CTA #} {# Single CTA #}
<div> <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" 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)"> style="background-color: var(--color-primary)">
Enter Shop Enter Shop
@@ -49,11 +49,11 @@
{% if header_pages or footer_pages %} {% if header_pages or footer_pages %}
<div class="mt-16 pt-8 border-t border-gray-200 dark:border-gray-700"> <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"> <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 Products
</a> </a>
{% for page in (header_pages or footer_pages)[:4] %} {% 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 }} {{ page.title }}
</a> </a>
{% endfor %} {% endfor %}

View File

@@ -1,7 +1,7 @@
{# app/templates/store/landing-modern.html #} {# app/templates/store/landing-modern.html #}
{# standalone #} {# standalone #}
{# Modern Landing Page Template - Feature Rich #} {# Modern Landing Page Template - Feature Rich #}
{% extends "shop/base.html" %} {% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %} {% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %} {% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
@@ -40,7 +40,7 @@
{# CTAs #} {# CTAs #}
<div class="flex flex-col sm:flex-row gap-6 justify-center animate-fade-in animation-delay-400"> <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" 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)"> style="background-color: var(--color-primary)">
<span>Start Shopping</span> <span>Start Shopping</span>
@@ -137,7 +137,7 @@
<p class="text-xl mb-10 opacity-90"> <p class="text-xl mb-10 opacity-90">
Explore our collection and find what you're looking for Explore our collection and find what you're looking for
</p> </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"> 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 Browse Products
<span class="w-5 h-5 ml-2" x-html="$icon('chevron-right', 'w-5 h-5')"></span> <span class="w-5 h-5 ml-2" x-html="$icon('chevron-right', 'w-5 h-5')"></span>

View File

@@ -146,6 +146,29 @@ def get_context_for_frontend(
f"[CONTEXT] {len(modules_with_providers)} modules have providers but none contributed" 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 # Add any extra context passed by the caller
if extra_context: if extra_context:
context.update(extra_context) context.update(extra_context)
@@ -318,6 +341,10 @@ def get_storefront_context(
) )
base_url = f"{full_prefix}{store.subdomain}/" 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 # Build storefront-specific base context
storefront_base = { storefront_base = {
"store": store, "store": store,
@@ -325,6 +352,11 @@ def get_storefront_context(
"clean_path": clean_path, "clean_path": clean_path,
"access_method": access_method, "access_method": access_method,
"base_url": base_url, "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 # If no db session, return just the base context

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card --> <!-- 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"> 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 items-center mb-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -36,7 +36,7 @@
</a> </a>
<!-- Profile Card --> <!-- 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"> 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 items-center mb-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -53,7 +53,7 @@
</a> </a>
<!-- Addresses Card --> <!-- 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"> 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 items-center mb-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -67,10 +67,10 @@
</a> </a>
<!-- Messages Card --> <!-- 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" 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-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 items-center mb-4">
<div class="flex-shrink-0 relative"> <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> <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 // Close modal
this.showLogoutModal = false; this.showLogoutModal = false;
fetch('/api/v1/shop/auth/logout', { fetch('/api/v1/storefront/auth/logout', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -157,14 +157,14 @@ function accountDashboard() {
// Redirect to login page // Redirect to login page
setTimeout(() => { setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login'; window.location.href = '{{ base_url }}storefront/account/login';
}, 500); }, 500);
} else { } else {
console.error('Logout failed with status:', response.status); console.error('Logout failed with status:', response.status);
this.showToast('Logout failed', 'error'); this.showToast('Logout failed', 'error');
// Still redirect on failure (cookie might be deleted) // Still redirect on failure (cookie might be deleted)
setTimeout(() => { setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login'; window.location.href = '{{ base_url }}storefront/account/login';
}, 1000); }, 1000);
} }
}) })
@@ -173,7 +173,7 @@ function accountDashboard() {
this.showToast('Logout failed', 'error'); this.showToast('Logout failed', 'error');
// Redirect anyway // Redirect anyway
setTimeout(() => { setTimeout(() => {
window.location.href = '{{ base_url }}shop/account/login'; window.location.href = '{{ base_url }}storefront/account/login';
}, 1000); }, 1000);
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header --> <!-- Page Header -->
<div class="mb-8"> <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> <span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Account Back to Account
</a> </a>
@@ -26,7 +26,7 @@
<span x-html="$icon('gift', 'w-16 h-16 mx-auto text-gray-300 dark:text-gray-600')"></span> <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> <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> <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" 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)"> style="background-color: var(--color-primary)">
<span x-html="$icon('plus', 'w-5 h-5 mr-2')"></span> <span x-html="$icon('plus', 'w-5 h-5 mr-2')"></span>
@@ -125,7 +125,7 @@
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Activity</h2> <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)"> class="text-sm font-medium hover:underline" style="color: var(--color-primary)">
View All View All
</a> </a>

View File

@@ -62,12 +62,12 @@
<!-- Actions --> <!-- Actions -->
<div class="space-y-3"> <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" class="block w-full py-3 px-4 text-white font-semibold rounded-lg transition-colors text-center"
style="background-color: var(--color-primary)"> style="background-color: var(--color-primary)">
View My Loyalty Dashboard View My Loyalty Dashboard
</a> </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"> 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 Continue Shopping
</a> </a>

View File

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

View File

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

View File

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

View File

@@ -11,11 +11,11 @@
<nav class="mb-6" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"> <ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li> <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>
<li class="flex items-center"> <li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span> <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>
<li class="flex items-center"> <li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span> <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"> <div class="ml-3">
<h3 class="text-lg font-medium text-red-800 dark:text-red-200">Error loading order</h3> <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> <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"> 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 Back to Orders
</a> </a>
@@ -314,7 +314,7 @@
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4"> <p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
If you have any questions about your order, please contact us. If you have any questions about your order, please contact us.
</p> </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" 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)"> 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> <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 --> <!-- Back Button -->
<div class="mt-8"> <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"> 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> <span class="h-4 w-4 mr-2" x-html="$icon('chevron-left', 'h-4 w-4')"></span>
Back to Orders Back to Orders
@@ -375,11 +375,11 @@ function shopOrderDetailPage() {
try { try {
const token = localStorage.getItem('customer_token'); const token = localStorage.getItem('customer_token');
if (!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; return;
} }
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`, { const response = await fetch(`/api/v1/storefront/orders/${this.orderId}`, {
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
@@ -389,7 +389,7 @@ function shopOrderDetailPage() {
if (response.status === 401) { if (response.status === 401) {
localStorage.removeItem('customer_token'); localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user'); 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; return;
} }
if (response.status === 404) { if (response.status === 404) {
@@ -501,11 +501,11 @@ function shopOrderDetailPage() {
try { try {
const token = localStorage.getItem('customer_token'); const token = localStorage.getItem('customer_token');
if (!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; 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', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
@@ -516,7 +516,7 @@ function shopOrderDetailPage() {
if (response.status === 401) { if (response.status === 401) {
localStorage.removeItem('customer_token'); localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user'); 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; return;
} }
if (response.status === 404) { if (response.status === 404) {

View File

@@ -11,7 +11,7 @@
<nav class="mb-6" aria-label="Breadcrumb"> <nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400"> <ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li> <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>
<li class="flex items-center"> <li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span> <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> <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> <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> <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" 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)"> style="background-color: var(--color-primary)">
Browse Products Browse Products
@@ -79,7 +79,7 @@
<span x-text="getStatusLabel(order.status)"></span> <span x-text="getStatusLabel(order.status)"></span>
</span> </span>
<!-- View Details Button --> <!-- 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"> 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 View Details
<span class="ml-2 h-4 w-4" x-html="$icon('chevron-right', 'h-4 w-4')"></span> <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 { try {
const token = localStorage.getItem('customer_token'); const token = localStorage.getItem('customer_token');
if (!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; return;
} }
const skip = (page - 1) * this.perPage; 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: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
@@ -182,7 +182,7 @@ function shopOrdersPage() {
if (response.status === 401) { if (response.status === 401) {
localStorage.removeItem('customer_token'); localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user'); 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; return;
} }
throw new Error('Failed to load orders'); throw new Error('Failed to load orders');

View File

@@ -63,7 +63,7 @@
{# Store Logo #} {# Store Logo #}
<div class="flex items-center"> <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 %} {% if theme.branding.logo %}
{# Show light logo in light mode, dark logo in dark mode #} {# Show light logo in light mode, dark logo in dark mode #}
<img x-show="!dark" <img x-show="!dark"
@@ -84,25 +84,28 @@
</a> </a>
</div> </div>
{# Navigation #} {# Navigation — Home is always shown, module items are dynamic #}
<nav class="hidden md:flex space-x-8"> <nav class="hidden md:flex space-x-8">
<a href="{{ base_url }}" class="text-gray-700 dark:text-gray-300 hover:text-primary"> <a href="{{ base_url }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
Home Home
</a> </a>
<a href="{{ base_url }}shop/products" class="text-gray-700 dark:text-gray-300 hover:text-primary"> {% for item in storefront_nav.get('nav', []) %}
Products <a href="{{ base_url }}{{ item.route }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
{{ _(item.label_key) }}
</a> </a>
<a href="{{ base_url }}shop/about" class="text-gray-700 dark:text-gray-300 hover:text-primary"> {% endfor %}
About {# CMS pages (About, Contact) are already dynamic via header_pages #}
</a> {% for page in header_pages|default([]) %}
<a href="{{ base_url }}shop/contact" class="text-gray-700 dark:text-gray-300 hover:text-primary"> <a href="{{ base_url }}storefront/{{ page.slug }}" class="text-gray-700 dark:text-gray-300 hover:text-primary">
Contact {{ page.title }}
</a> </a>
{% endfor %}
</nav> </nav>
{# Right side actions #} {# Right side actions #}
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
{% if 'catalog' in enabled_modules|default([]) %}
{# Search #} {# Search #}
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"> <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"> <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> d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg> </svg>
</button> </button>
{% endif %}
{% if 'cart' in enabled_modules|default([]) %}
{# Cart #} {# 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"> <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" <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> 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)"> style="background-color: var(--color-accent)">
</span> </span>
</a> </a>
{% endif %}
{# Theme toggle #} {# Theme toggle #}
<button @click="toggleTheme()" <button @click="toggleTheme()"
@@ -181,7 +187,7 @@
{% endif %} {% endif %}
{# Account #} {# 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"> <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" <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> 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> <div>
<h4 class="font-semibold mb-4">Quick Links</h4> <h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2"> <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 %} {% 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 %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -269,7 +277,7 @@
<h4 class="font-semibold mb-4">Information</h4> <h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2"> <ul class="space-y-2">
{% for page in col2_pages %} {% 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 %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -279,18 +287,20 @@
<div> <div>
<h4 class="font-semibold mb-4">Quick Links</h4> <h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2"> <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 }}shop/about" class="text-gray-600 hover:text-primary dark:text-gray-400">About Us</a></li> <li><a href="{{ base_url }}storefront/products" class="text-gray-600 hover:text-primary dark:text-gray-400">Products</a></li>
<li><a href="{{ base_url }}shop/contact" class="text-gray-600 hover:text-primary dark:text-gray-400">Contact</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> </ul>
</div> </div>
<div> <div>
<h4 class="font-semibold mb-4">Information</h4> <h4 class="font-semibold mb-4">Information</h4>
<ul class="space-y-2"> <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 }}storefront/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 }}storefront/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/returns" class="text-gray-600 hover:text-primary dark:text-gray-400">Returns</a></li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@@ -321,7 +331,7 @@
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script> <script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
{# 4. Base Shop Layout (Alpine.js component - must load before Alpine) #} {# 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 #} {# 5. Utilities #}
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script> <script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> 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 Go Back
</a> </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"> 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 Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}401 - Authentication Required{% endblock %} {% block title %}401 - Authentication Required{% endblock %}
{% block action_buttons %} {% 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"> 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 Log In
</a> </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"> 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 Create Account
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}403 - Access Restricted{% endblock %} {% block title %}403 - Access Restricted{% endblock %}
{% block action_buttons %} {% 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"> 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 Log In
</a> </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"> 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 Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -7,16 +7,16 @@
{% block title %}404 - Page Not Found{% endblock %} {% block title %}404 - Page Not Found{% endblock %}
{% block action_buttons %} {% 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"> 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 Continue Shopping
</a> </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"> 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 View All Products
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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. 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 %} {% endblock %}

View File

@@ -24,12 +24,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> 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 Go Back and Fix
</a> </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"> 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 Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -21,12 +21,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> 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 Try Again
</a> </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"> 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 Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -7,7 +7,7 @@
{% block title %}500 - Something Went Wrong{% endblock %} {% block title %}500 - Something Went Wrong{% endblock %}
{% block action_buttons %} {% 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"> 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 Go to Home
</a> </a>
@@ -18,5 +18,5 @@
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -11,12 +11,12 @@
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg"> 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 Try Again
</a> </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"> 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 Go to Home
</a> </a>
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

@@ -8,7 +8,7 @@
<title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if store %} | {{ store.name }}{% endif %}</title> <title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if store %} | {{ store.name }}{% endif %}</title>
{# Tailwind CSS #} {# 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 #} {# Store theme colors via CSS variables #}
<style> <style>
@@ -76,11 +76,11 @@
{# Action Buttons #} {# Action Buttons #}
<div class="flex gap-4 justify-center flex-wrap mt-8"> <div class="flex gap-4 justify-center flex-wrap mt-8">
{% block action_buttons %} {% 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"> 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 Continue Shopping
</a> </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"> 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 Contact Us
</a> </a>
@@ -92,7 +92,7 @@
{# Support Link #} {# Support Link #}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500"> <div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
{% block support_link %} {% 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 %} {% endblock %}
</div> </div>

View File

@@ -7,7 +7,7 @@
{% block title %}{{ status_code }} - {{ status_name }}{% endblock %} {% block title %}{{ status_code }} - {{ status_name }}{% endblock %}
{% block action_buttons %} {% 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"> 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 Continue Shopping
</a> </a>
@@ -18,5 +18,5 @@
{% endblock %} {% endblock %}
{% block support_link %} {% 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 %} {% endblock %}

View File

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

34
main.py
View File

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

View File

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

View File

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