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

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

View File

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

View File

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

View File

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

View File

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

View File

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