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:
@@ -125,6 +125,36 @@ catalog_module = ModuleDefinition(
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.STOREFRONT: [
|
||||
MenuSectionDefinition(
|
||||
id="nav",
|
||||
label_key=None,
|
||||
order=10,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="products",
|
||||
label_key="storefront.nav.products",
|
||||
icon="shopping-bag",
|
||||
route="storefront/products",
|
||||
order=10,
|
||||
),
|
||||
],
|
||||
),
|
||||
MenuSectionDefinition(
|
||||
id="actions",
|
||||
label_key=None,
|
||||
order=10,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="search",
|
||||
label_key="storefront.actions.search",
|
||||
icon="search",
|
||||
route="",
|
||||
order=10,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
# Metrics provider for dashboard statistics
|
||||
metrics_provider=_get_metrics_provider,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||||
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="categoryName">{{ category_slug | replace('-', ' ') | title if category_slug else 'Category' }}</span>
|
||||
</div>
|
||||
@@ -61,14 +61,14 @@
|
||||
<div x-show="!loading && products.length > 0" class="product-grid">
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`">
|
||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
<a :href="`{{ base_url }}storefront/products/${product.id}`">
|
||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
||||
:alt="product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
|
||||
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
|
||||
@@ -99,7 +99,7 @@
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Check back later or browse other categories.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse All Products
|
||||
</a>
|
||||
</div>
|
||||
@@ -213,9 +213,9 @@ document.addEventListener('alpine:init', () => {
|
||||
params.append('sort', this.sortBy);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading category products from /api/v1/shop/products?${params}`);
|
||||
console.log(`[SHOP] Loading category products from /api/v1/storefront/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/products?${params}`);
|
||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -246,7 +246,7 @@ document.addEventListener('alpine:init', () => {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<a href="{{ base_url }}shop/products" class="hover:text-primary">Products</a>
|
||||
<a href="{{ base_url }}storefront/products" class="hover:text-primary">Products</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium" x-text="product?.marketplace_product?.title || 'Product'">Product</span>
|
||||
</div>
|
||||
@@ -29,8 +29,8 @@
|
||||
<div class="product-images">
|
||||
<div class="main-image bg-white dark:bg-gray-800 rounded-lg overflow-hidden mb-4">
|
||||
<img
|
||||
:src="selectedImage || '/static/shop/img/placeholder.svg'"
|
||||
@error="selectedImage = '/static/shop/img/placeholder.svg'"
|
||||
:src="selectedImage || '/static/storefront/img/placeholder.svg'"
|
||||
@error="selectedImage = '/static/storefront/img/placeholder.svg'"
|
||||
:alt="product?.marketplace_product?.title"
|
||||
class="w-full h-auto object-contain"
|
||||
style="max-height: 600px;"
|
||||
@@ -186,16 +186,16 @@
|
||||
<div class="product-grid">
|
||||
<template x-for="related in relatedProducts" :key="related.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer">
|
||||
<a :href="`{{ base_url }}shop/products/${related.id}`">
|
||||
<a :href="`{{ base_url }}storefront/products/${related.id}`">
|
||||
<img
|
||||
:src="related.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
:src="related.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
||||
:alt="related.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover"
|
||||
>
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${related.id}`">
|
||||
<a :href="`{{ base_url }}storefront/products/${related.id}`">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="related.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-2xl font-bold text-primary">
|
||||
@@ -276,7 +276,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
try {
|
||||
console.log(`[SHOP] Loading product ${this.productId}...`);
|
||||
const response = await fetch(`/api/v1/shop/products/${this.productId}`);
|
||||
const response = await fetch(`/api/v1/storefront/products/${this.productId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Product not found');
|
||||
@@ -301,7 +301,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.showToast('Failed to load product', 'error');
|
||||
// Redirect back to products after error
|
||||
setTimeout(() => {
|
||||
window.location.href = '{{ base_url }}shop/products';
|
||||
window.location.href = '{{ base_url }}storefront/products';
|
||||
}, 2000);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
@@ -311,7 +311,7 @@ document.addEventListener('alpine:init', () => {
|
||||
// Load related products
|
||||
async loadRelatedProducts() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/shop/products?limit=4`);
|
||||
const response = await fetch(`/api/v1/storefront/products?limit=4`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
@@ -368,7 +368,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.addingToCart = true;
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: parseInt(this.productId),
|
||||
quantity: this.quantity
|
||||
|
||||
@@ -73,14 +73,14 @@
|
||||
<div x-show="!loading && products.length > 0" class="product-grid">
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`">
|
||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
<a :href="`{{ base_url }}storefront/products/${product.id}`">
|
||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
||||
:alt="product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
|
||||
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
|
||||
@@ -178,9 +178,9 @@ document.addEventListener('alpine:init', () => {
|
||||
params.append('search', this.filters.search);
|
||||
}
|
||||
|
||||
console.log(`[SHOP] Loading products from /api/v1/shop/products?${params}`);
|
||||
console.log(`[SHOP] Loading products from /api/v1/storefront/products?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/products?${params}`);
|
||||
const response = await fetch(`/api/v1/storefront/products?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -211,7 +211,7 @@ document.addEventListener('alpine:init', () => {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
|
||||
@@ -80,14 +80,14 @@
|
||||
<div x-show="!loading && query && products.length > 0" class="product-grid">
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`">
|
||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
<a :href="`{{ base_url }}storefront/products/${product.id}`">
|
||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
||||
:alt="product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${product.id}`" class="block">
|
||||
<a :href="`{{ base_url }}storefront/products/${product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="product.marketplace_product?.description"></p>
|
||||
@@ -254,9 +254,9 @@ document.addEventListener('alpine:init', () => {
|
||||
limit: this.perPage
|
||||
});
|
||||
|
||||
console.log(`[SHOP] Searching: /api/v1/shop/products/search?${params}`);
|
||||
console.log(`[SHOP] Searching: /api/v1/storefront/products/search?${params}`);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/products/search?${params}`);
|
||||
const response = await fetch(`/api/v1/storefront/products/search?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -292,7 +292,7 @@ document.addEventListener('alpine:init', () => {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="breadcrumb mb-6">
|
||||
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
|
||||
<span>/</span>
|
||||
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">Account</a>
|
||||
<a href="{{ base_url }}storefront/account/dashboard" class="hover:text-primary">Account</a>
|
||||
<span>/</span>
|
||||
<span class="text-gray-900 dark:text-gray-200 font-medium">Wishlist</span>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Log in to your account to view and manage your wishlist.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
<a href="{{ base_url }}storefront/account/login" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
@@ -64,14 +64,14 @@
|
||||
<span x-html="$icon('heart', 'w-5 h-5 text-red-500 fill-current')"></span>
|
||||
</button>
|
||||
|
||||
<a :href="`{{ base_url }}shop/products/${item.product.id}`">
|
||||
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/shop/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/shop/img/placeholder.svg'"
|
||||
<a :href="`{{ base_url }}storefront/products/${item.product.id}`">
|
||||
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
||||
:alt="item.product.marketplace_product?.title"
|
||||
class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="p-4">
|
||||
<a :href="`{{ base_url }}shop/products/${item.product.id}`" class="block">
|
||||
<a :href="`{{ base_url }}storefront/products/${item.product.id}`" class="block">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2" x-text="item.product.marketplace_product?.title"></h3>
|
||||
</a>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2" x-text="item.product.marketplace_product?.description"></p>
|
||||
@@ -122,7 +122,7 @@
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Save items you like by clicking the heart icon on product pages.
|
||||
</p>
|
||||
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
<a href="{{ base_url }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors" style="background-color: var(--color-primary)">
|
||||
Browse Products
|
||||
</a>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
async checkLoginStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/shop/customers/me');
|
||||
const response = await fetch('/api/v1/storefront/customers/me');
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -170,7 +170,7 @@ document.addEventListener('alpine:init', () => {
|
||||
try {
|
||||
console.log('[SHOP] Loading wishlist...');
|
||||
|
||||
const response = await fetch('/api/v1/shop/wishlist');
|
||||
const response = await fetch('/api/v1/storefront/wishlist');
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
@@ -197,7 +197,7 @@ document.addEventListener('alpine:init', () => {
|
||||
try {
|
||||
console.log('[SHOP] Removing from wishlist:', item);
|
||||
|
||||
const response = await fetch(`/api/v1/shop/wishlist/${item.id}`, {
|
||||
const response = await fetch(`/api/v1/storefront/wishlist/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ document.addEventListener('alpine:init', () => {
|
||||
console.log('[SHOP] Adding to cart:', product);
|
||||
|
||||
try {
|
||||
const url = `/api/v1/shop/cart/${this.sessionId}/items`;
|
||||
const url = `/api/v1/storefront/cart/${this.sessionId}/items`;
|
||||
const payload = {
|
||||
product_id: product.id,
|
||||
quantity: 1
|
||||
|
||||
Reference in New Issue
Block a user