Compare commits
2 Commits
6c78827c7f
...
f47c680cb8
| Author | SHA1 | Date | |
|---|---|---|---|
| f47c680cb8 | |||
| 32e4aa6564 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -168,6 +168,11 @@ deployment-local/
|
|||||||
secrets/
|
secrets/
|
||||||
credentials/
|
credentials/
|
||||||
|
|
||||||
|
# Google Cloud service account keys
|
||||||
|
*-service-account.json
|
||||||
|
google-wallet-sa.json
|
||||||
|
orion-*.json
|
||||||
|
|
||||||
# Alembic
|
# Alembic
|
||||||
# Note: Keep alembic/versions/ tracked for migrations
|
# Note: Keep alembic/versions/ tracked for migrations
|
||||||
# alembic/versions/*.pyc is already covered by __pycache__
|
# alembic/versions/*.pyc is already covered by __pycache__
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GOOGLE WALLET (LOYALTY MODULE)
|
||||||
|
# =============================================================================
|
||||||
|
loyalty_google_issuer_id: str | None = None
|
||||||
|
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -434,14 +434,19 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
|
|
||||||
base_url = "/"
|
base_url = "/"
|
||||||
if access_method == "path" and store:
|
if access_method == "path" and store:
|
||||||
full_prefix = (
|
platform = getattr(request.state, "platform", None)
|
||||||
store_context.get("full_prefix", "/store/")
|
platform_original_path = getattr(request.state, "platform_original_path", None)
|
||||||
if store_context
|
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
|
||||||
else "/store/"
|
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
|
||||||
)
|
else:
|
||||||
base_url = f"{full_prefix}{store.subdomain}/"
|
full_prefix = (
|
||||||
|
store_context.get("full_prefix", "/storefront/")
|
||||||
|
if store_context
|
||||||
|
else "/storefront/"
|
||||||
|
)
|
||||||
|
base_url = f"{full_prefix}{store.store_code}/"
|
||||||
|
|
||||||
login_url = f"{base_url}storefront/account/login"
|
login_url = f"{base_url}account/login"
|
||||||
logger.debug(f"Redirecting to {login_url}")
|
logger.debug(f"Redirecting to {login_url}")
|
||||||
return RedirectResponse(url=login_url, status_code=302)
|
return RedirectResponse(url=login_url, status_code=302)
|
||||||
# Fallback to root for unknown contexts (PLATFORM)
|
# Fallback to root for unknown contexts (PLATFORM)
|
||||||
|
|||||||
@@ -352,7 +352,7 @@
|
|||||||
x-transition:leave="transform transition ease-in-out duration-300"
|
x-transition:leave="transform transition ease-in-out duration-300"
|
||||||
x-transition:leave-start="translate-x-0"
|
x-transition:leave-start="translate-x-0"
|
||||||
x-transition:leave-end="translate-x-full"
|
x-transition:leave-end="translate-x-full"
|
||||||
class="w-screen max-w-lg"
|
class="w-screen max-w-lg h-full"
|
||||||
>
|
>
|
||||||
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
|
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|||||||
@@ -14,7 +14,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 }}storefront/products" class="hover:text-primary">Products</a>
|
<a href="{{ base_url }}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>
|
||||||
@@ -40,7 +40,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 }}storefront/products" class="inline-block px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
|
<a href="{{ base_url }}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>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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">
|
<a href="{{ base_url }}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>
|
||||||
|
|
||||||
@@ -309,9 +309,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 }}storefront/account/login?return={{ base_url }}storefront/checkout';
|
window.location.href = '{{ base_url }}account/login?return={{ base_url }}checkout';
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '{{ base_url }}storefront/checkout';
|
window.location.href = '{{ base_url }}checkout';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }}storefront/products" class="hover:text-primary">Products</a>
|
<a href="{{ base_url }}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 }}storefront/products/${product.id}`">
|
<a :href="`{{ base_url }}products/${product.id}`">
|
||||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||||
@error="$el.src = '/static/storefront/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 }}storefront/products/${product.id}`" class="block">
|
<a :href="`{{ base_url }}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 }}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)">
|
<a href="{{ base_url }}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>
|
||||||
|
|||||||
@@ -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 }}storefront/products" class="hover:text-primary">Products</a>
|
<a href="{{ base_url }}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>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
<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 }}storefront/products/${related.id}`">
|
<a :href="`{{ base_url }}products/${related.id}`">
|
||||||
<img
|
<img
|
||||||
:src="related.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
:src="related.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||||
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
@error="$el.src = '/static/storefront/img/placeholder.svg'"
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<a :href="`{{ base_url }}storefront/products/${related.id}`">
|
<a :href="`{{ base_url }}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">
|
||||||
@@ -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 }}storefront/products';
|
window.location.href = '{{ base_url }}products';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|||||||
@@ -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 }}storefront/products/${product.id}`">
|
<a :href="`{{ base_url }}products/${product.id}`">
|
||||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||||
@error="$el.src = '/static/storefront/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 }}storefront/products/${product.id}`" class="block">
|
<a :href="`{{ base_url }}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>
|
||||||
|
|||||||
@@ -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 }}storefront/products/${product.id}`">
|
<a :href="`{{ base_url }}products/${product.id}`">
|
||||||
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
<img loading="lazy" :src="product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||||
@error="$el.src = '/static/storefront/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 }}storefront/products/${product.id}`" class="block">
|
<a :href="`{{ base_url }}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>
|
||||||
|
|||||||
@@ -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 }}storefront/account/dashboard" class="hover:text-primary">Account</a>
|
<a href="{{ base_url }}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 }}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)">
|
<a href="{{ base_url }}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 }}storefront/products/${item.product.id}`">
|
<a :href="`{{ base_url }}products/${item.product.id}`">
|
||||||
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
<img loading="lazy" :src="item.product.marketplace_product?.image_link || '/static/storefront/img/placeholder.svg'"
|
||||||
@error="$el.src = '/static/storefront/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 }}storefront/products/${item.product.id}`" class="block">
|
<a :href="`{{ base_url }}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 }}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)">
|
<a href="{{ base_url }}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>
|
||||||
|
|||||||
@@ -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 }}storefront/" class="hover:text-primary">Home</a></li>
|
<li><a href="{{ base_url }}" 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 }}storefront/cart" class="hover:text-primary">Cart</a>
|
<a href="{{ base_url }}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 }}storefront/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
|
<a href="{{ base_url }}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>
|
||||||
@@ -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 }}storefront/order-confirmation?order=' + order.order_number;
|
window.location.href = '{{ base_url }}order-confirmation?order=' + order.order_number;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CHECKOUT] Error placing order:', error);
|
console.error('[CHECKOUT] Error placing order:', error);
|
||||||
|
|||||||
@@ -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 }}storefront/"
|
<a href="{{ base_url }}"
|
||||||
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 }}storefront/products"
|
<a href="{{ base_url }}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 }}storefront/{{ page.slug }}"
|
<a href="{{ base_url }}{{ 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 }}storefront/about"
|
<a href="{{ base_url }}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 }}storefront/contact"
|
<a href="{{ base_url }}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">
|
||||||
|
|||||||
@@ -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 }}storefront/"
|
<a href="{{ base_url }}"
|
||||||
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 }}storefront/products"
|
<a href="{{ base_url }}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 }}storefront/{{ page.slug }}"
|
<a href="{{ base_url }}{{ 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 }}storefront/about"
|
<a href="{{ base_url }}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 }}storefront/contact"
|
<a href="{{ base_url }}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 }}storefront/products"
|
<a href="{{ base_url }}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>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
{# Single CTA #}
|
{# Single CTA #}
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ base_url }}storefront/"
|
<a href="{{ base_url }}"
|
||||||
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 }}storefront/products" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
|
<a href="{{ base_url }}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 }}storefront/{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
|
<a href="{{ base_url }}{{ page.slug }}" class="text-gray-600 dark:text-gray-400 hover:text-primary transition-colors">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -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 }}storefront/"
|
<a href="{{ base_url }}"
|
||||||
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 }}storefront/products"
|
<a href="{{ base_url }}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>
|
||||||
|
|||||||
@@ -178,23 +178,8 @@ def customer_login(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate cookie path based on store access method
|
# Set cookie with path=/ so it's sent with all requests
|
||||||
store_context = getattr(request.state, "store_context", None)
|
# (platform prefix varies between dev and prod, broad path avoids mismatch)
|
||||||
access_method = (
|
|
||||||
store_context.get("detection_method", "unknown")
|
|
||||||
if store_context
|
|
||||||
else "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
cookie_path = "/storefront"
|
|
||||||
if access_method == "path":
|
|
||||||
full_prefix = (
|
|
||||||
store_context.get("full_prefix", "/store/")
|
|
||||||
if store_context
|
|
||||||
else "/store/"
|
|
||||||
)
|
|
||||||
cookie_path = f"{full_prefix}{store.subdomain}/storefront"
|
|
||||||
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="customer_token",
|
key="customer_token",
|
||||||
value=login_result["token_data"]["access_token"],
|
value=login_result["token_data"]["access_token"],
|
||||||
@@ -202,12 +187,12 @@ def customer_login(
|
|||||||
secure=should_use_secure_cookies(),
|
secure=should_use_secure_cookies(),
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=login_result["token_data"]["expires_in"],
|
max_age=login_result["token_data"]["expires_in"],
|
||||||
path=cookie_path,
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
||||||
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
|
f"(path=/, httponly=True, secure={should_use_secure_cookies()})",
|
||||||
)
|
)
|
||||||
|
|
||||||
return CustomerLoginResponse(
|
return CustomerLoginResponse(
|
||||||
@@ -237,25 +222,9 @@ def customer_logout(request: Request, response: Response):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
store_context = getattr(request.state, "store_context", None)
|
response.delete_cookie(key="customer_token", path="/")
|
||||||
access_method = (
|
|
||||||
store_context.get("detection_method", "unknown")
|
|
||||||
if store_context
|
|
||||||
else "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
cookie_path = "/storefront"
|
logger.debug("Deleted customer_token cookie (path=/)")
|
||||||
if access_method == "path" and store:
|
|
||||||
full_prefix = (
|
|
||||||
store_context.get("full_prefix", "/store/")
|
|
||||||
if store_context
|
|
||||||
else "/store/"
|
|
||||||
)
|
|
||||||
cookie_path = f"{full_prefix}{store.subdomain}/storefront"
|
|
||||||
|
|
||||||
response.delete_cookie(key="customer_token", path=cookie_path)
|
|
||||||
|
|
||||||
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
|
|
||||||
|
|
||||||
return LogoutResponse(message="Logged out successfully")
|
return LogoutResponse(message="Logged out successfully")
|
||||||
|
|
||||||
|
|||||||
@@ -153,14 +153,19 @@ async def shop_account_root(request: Request):
|
|||||||
|
|
||||||
base_url = "/"
|
base_url = "/"
|
||||||
if access_method == "path" and store:
|
if access_method == "path" and store:
|
||||||
full_prefix = (
|
platform = getattr(request.state, "platform", None)
|
||||||
store_context.get("full_prefix", "/store/")
|
platform_original_path = getattr(request.state, "platform_original_path", None)
|
||||||
if store_context
|
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
|
||||||
else "/store/"
|
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
|
||||||
)
|
else:
|
||||||
base_url = f"{full_prefix}{store.subdomain}/"
|
full_prefix = (
|
||||||
|
store_context.get("full_prefix", "/storefront/")
|
||||||
|
if store_context
|
||||||
|
else "/storefront/"
|
||||||
|
)
|
||||||
|
base_url = f"{full_prefix}{store.store_code}/"
|
||||||
|
|
||||||
return RedirectResponse(url=f"{base_url}storefront/account/dashboard", status_code=302)
|
return RedirectResponse(url=f"{base_url}account/dashboard", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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 }}storefront/account/login';
|
window.location.href = '{{ base_url }}account/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error('Failed to load addresses');
|
throw new Error('Failed to load addresses');
|
||||||
|
|||||||
@@ -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 }}storefront/account/orders"
|
<a href="{{ base_url }}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 }}storefront/account/profile"
|
<a href="{{ base_url }}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 }}storefront/account/addresses"
|
<a href="{{ base_url }}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,7 +67,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Messages Card -->
|
<!-- Messages Card -->
|
||||||
<a href="{{ base_url }}storefront/account/messages"
|
<a href="{{ base_url }}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/storefront/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(() => {})">
|
||||||
@@ -157,14 +157,14 @@ function accountDashboard() {
|
|||||||
|
|
||||||
// Redirect to login page
|
// Redirect to login page
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '{{ base_url }}storefront/account/login';
|
window.location.href = '{{ base_url }}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 }}storefront/account/login';
|
window.location.href = '{{ base_url }}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 }}storefront/account/login';
|
window.location.href = '{{ base_url }}account/login';
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}storefront/account/login">
|
href="{{ base_url }}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 }}storefront/">
|
href="{{ base_url }}">
|
||||||
← Continue shopping
|
← Continue shopping
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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 }}storefront/account/forgot-password"
|
<a href="{{ base_url }}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 }}storefront/account/register">
|
href="{{ base_url }}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 }}storefront/">
|
href="{{ base_url }}">
|
||||||
← Continue shopping
|
← Continue shopping
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -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 }}storefront/account';
|
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}account';
|
||||||
window.location.href = returnUrl;
|
window.location.href = returnUrl;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
|
<a href="{{ base_url }}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,7 +330,7 @@ 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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error('Failed to load profile');
|
throw new Error('Failed to load profile');
|
||||||
@@ -380,7 +380,7 @@ 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 }}storefront/account/login';
|
window.location.href = '{{ base_url }}account/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +429,7 @@ 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 }}storefront/account/login';
|
window.location.href = '{{ base_url }}account/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ 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 }}storefront/account/login';
|
window.location.href = '{{ base_url }}account/login';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}storefront/account/login">
|
href="{{ base_url }}account/login">
|
||||||
Sign in instead
|
Sign in instead
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
|
|
||||||
// Redirect to login after 2 seconds
|
// Redirect to login after 2 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '{{ base_url }}storefront/account/login?registered=true';
|
window.location.href = '{{ base_url }}account/login?registered=true';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
Please request a new password reset link.
|
Please request a new password reset link.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="{{ base_url }}storefront/account/forgot-password"
|
<a href="{{ base_url }}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 }}storefront/account/login"
|
<a href="{{ base_url }}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 }}storefront/account/login">
|
href="{{ base_url }}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 }}storefront/">
|
href="{{ base_url }}">
|
||||||
← Continue shopping
|
← Continue shopping
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.modules.loyalty.schemas import (
|
|||||||
CardResponse,
|
CardResponse,
|
||||||
ProgramResponse,
|
ProgramResponse,
|
||||||
)
|
)
|
||||||
from app.modules.loyalty.services import card_service, program_service
|
from app.modules.loyalty.services import card_service, program_service, wallet_service
|
||||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||||
|
|
||||||
storefront_router = APIRouter()
|
storefront_router = APIRouter()
|
||||||
@@ -89,8 +89,32 @@ def self_enroll(
|
|||||||
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
||||||
|
|
||||||
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
||||||
|
program = card.program
|
||||||
|
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||||
|
|
||||||
return CardResponse.model_validate(card)
|
return {
|
||||||
|
"card": CardResponse(
|
||||||
|
id=card.id,
|
||||||
|
card_number=card.card_number,
|
||||||
|
customer_id=card.customer_id,
|
||||||
|
merchant_id=card.merchant_id,
|
||||||
|
program_id=card.program_id,
|
||||||
|
enrolled_at_store_id=card.enrolled_at_store_id,
|
||||||
|
stamp_count=card.stamp_count,
|
||||||
|
stamps_target=program.stamps_target,
|
||||||
|
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||||
|
total_stamps_earned=card.total_stamps_earned,
|
||||||
|
stamps_redeemed=card.stamps_redeemed,
|
||||||
|
points_balance=card.points_balance,
|
||||||
|
total_points_earned=card.total_points_earned,
|
||||||
|
points_redeemed=card.points_redeemed,
|
||||||
|
is_active=card.is_active,
|
||||||
|
created_at=card.created_at,
|
||||||
|
has_google_wallet=bool(card.google_object_id),
|
||||||
|
has_apple_wallet=bool(card.apple_serial_number),
|
||||||
|
),
|
||||||
|
"wallet_urls": wallet_urls,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -137,10 +161,32 @@ def get_my_card(
|
|||||||
program_response.is_points_enabled = program.is_points_enabled
|
program_response.is_points_enabled = program.is_points_enabled
|
||||||
program_response.display_name = program.display_name
|
program_response.display_name = program.display_name
|
||||||
|
|
||||||
|
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"card": CardResponse.model_validate(card),
|
"card": CardResponse(
|
||||||
|
id=card.id,
|
||||||
|
card_number=card.card_number,
|
||||||
|
customer_id=card.customer_id,
|
||||||
|
merchant_id=card.merchant_id,
|
||||||
|
program_id=card.program_id,
|
||||||
|
enrolled_at_store_id=card.enrolled_at_store_id,
|
||||||
|
stamp_count=card.stamp_count,
|
||||||
|
stamps_target=program.stamps_target,
|
||||||
|
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||||
|
total_stamps_earned=card.total_stamps_earned,
|
||||||
|
stamps_redeemed=card.stamps_redeemed,
|
||||||
|
points_balance=card.points_balance,
|
||||||
|
total_points_earned=card.total_points_earned,
|
||||||
|
points_redeemed=card.points_redeemed,
|
||||||
|
is_active=card.is_active,
|
||||||
|
created_at=card.created_at,
|
||||||
|
has_google_wallet=bool(card.google_object_id),
|
||||||
|
has_apple_wallet=bool(card.apple_serial_number),
|
||||||
|
),
|
||||||
"program": program_response,
|
"program": program_response,
|
||||||
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
||||||
|
"wallet_urls": wallet_urls,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,12 @@ class CardService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Create wallet objects (Google Wallet, Apple Wallet)
|
||||||
|
# Lazy import to avoid circular imports; exception-safe (logs but doesn't raise)
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.create_wallet_objects(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
||||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated points balance
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added {points_earned} points to card {card.id} at store {store_id} "
|
f"Added {points_earned} points to card {card.id} at store {store_id} "
|
||||||
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||||
@@ -295,6 +300,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated points balance
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Redeemed {points_required} points from card {card.id} at store {store_id} "
|
f"Redeemed {points_required} points from card {card.id} at store {store_id} "
|
||||||
f"(reward: {reward_name}, balance: {card.points_balance})"
|
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||||
@@ -437,6 +447,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated points balance
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Voided {actual_voided} points from card {card.id} at store {store_id} "
|
f"Voided {actual_voided} points from card {card.id} at store {store_id} "
|
||||||
f"(balance: {card.points_balance})"
|
f"(balance: {card.points_balance})"
|
||||||
@@ -523,6 +538,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated points balance
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
||||||
f"(reason: {reason}, balance: {card.points_balance})"
|
f"(reason: {reason}, balance: {card.points_balance})"
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ class StampService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated stamp count
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
stamps_today += 1
|
stamps_today += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -273,6 +278,11 @@ class StampService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated stamp count
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Redeemed stamps from card {card.id} at store {store_id} "
|
f"Redeemed stamps from card {card.id} at store {store_id} "
|
||||||
f"(reward: {program.stamps_reward_description}, "
|
f"(reward: {program.stamps_reward_description}, "
|
||||||
@@ -400,6 +410,11 @@ class StampService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
|
|
||||||
|
# Sync wallet passes with updated stamp count
|
||||||
|
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||||
|
|
||||||
|
wallet_service.sync_card_to_wallets(db, card)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
|
f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
|
||||||
f"(balance: {card.stamp_count})"
|
f"(balance: {card.stamp_count})"
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ class WalletService:
|
|||||||
|
|
||||||
program = card.program
|
program = card.program
|
||||||
|
|
||||||
# Google Wallet
|
# Google Wallet — platform-wide config via env vars
|
||||||
if program.google_issuer_id or program.google_class_id:
|
if google_wallet_service.is_configured or program.google_class_id:
|
||||||
try:
|
try:
|
||||||
urls["google_wallet_url"] = google_wallet_service.get_save_url(db, card)
|
urls["google_wallet_url"] = google_wallet_service.get_save_url(db, card)
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as e: # noqa: EXC003
|
||||||
@@ -131,8 +131,8 @@ class WalletService:
|
|||||||
|
|
||||||
program = card.program
|
program = card.program
|
||||||
|
|
||||||
# Create Google Wallet object
|
# Create Google Wallet object — platform-wide config via env vars
|
||||||
if program.google_issuer_id:
|
if google_wallet_service.is_configured:
|
||||||
try:
|
try:
|
||||||
google_wallet_service.create_object(db, card)
|
google_wallet_service.create_object(db, card)
|
||||||
results["google_wallet"] = True
|
results["google_wallet"] = True
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ function customerLoyaltyDashboard() {
|
|||||||
transactions: [],
|
transactions: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
|
|
||||||
|
// Wallet
|
||||||
|
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
loading: false,
|
loading: false,
|
||||||
showBarcode: false,
|
showBarcode: false,
|
||||||
@@ -43,6 +46,7 @@ function customerLoyaltyDashboard() {
|
|||||||
this.program = response.program;
|
this.program = response.program;
|
||||||
this.rewards = response.program?.points_rewards || [];
|
this.rewards = response.program?.points_rewards || [];
|
||||||
this.locations = response.locations || [];
|
this.locations = response.locations || [];
|
||||||
|
this.walletUrls = response.wallet_urls || { google_wallet_url: null, apple_wallet_url: null };
|
||||||
console.log('Loyalty card loaded:', this.card?.card_number);
|
console.log('Loyalty card loaded:', this.card?.card_number);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 }}storefront/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 }}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 }}storefront/loyalty/join"
|
<a href="{{ base_url }}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 }}storefront/account/loyalty/history"
|
<a href="{{ base_url }}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>
|
||||||
@@ -203,14 +203,20 @@
|
|||||||
|
|
||||||
<!-- Wallet Buttons -->
|
<!-- Wallet Buttons -->
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2 mb-4">
|
||||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
<template x-if="walletUrls.apple_wallet_url">
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||||
Add to Apple Wallet
|
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||||
</button>
|
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
Add to Apple Wallet
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
</a>
|
||||||
Add to Google Wallet
|
</template>
|
||||||
</button>
|
<template x-if="walletUrls.google_wallet_url">
|
||||||
|
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||||
|
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||||
|
Add to Google Wallet
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="showBarcode = false"
|
<button @click="showBarcode = false"
|
||||||
|
|||||||
@@ -29,14 +29,20 @@
|
|||||||
Save your card to your phone for easy access:
|
Save your card to your phone for easy access:
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
<template x-if="walletUrls.apple_wallet_url">
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||||
Add to Apple Wallet
|
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||||
</button>
|
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
Add to Apple Wallet
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
</a>
|
||||||
Add to Google Wallet
|
</template>
|
||||||
</button>
|
<template x-if="walletUrls.google_wallet_url">
|
||||||
|
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||||
|
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||||
|
Add to Google Wallet
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,12 +68,12 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<a href="{{ base_url }}storefront/account/loyalty"
|
<a href="{{ base_url }}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 }}storefront"
|
<a href="{{ base_url }}"
|
||||||
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>
|
||||||
@@ -80,7 +86,19 @@
|
|||||||
<script>
|
<script>
|
||||||
function customerLoyaltyEnrollSuccess() {
|
function customerLoyaltyEnrollSuccess() {
|
||||||
return {
|
return {
|
||||||
...data()
|
...data(),
|
||||||
|
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/storefront/loyalty/card');
|
||||||
|
if (response && response.wallet_urls) {
|
||||||
|
this.walletUrls = response.wallet_urls;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Customer may not be authenticated (public enrollment)
|
||||||
|
console.log('Could not load wallet URLs:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 }}storefront/account/loyalty" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-primary mb-4">
|
<a href="{{ base_url }}account/loyalty" 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 Loyalty
|
Back to Loyalty
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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 }}storefront/account/dashboard"
|
<a href="{{ base_url }}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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error('Failed to load conversations');
|
throw new Error('Failed to load conversations');
|
||||||
@@ -335,7 +335,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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ function shopMessages() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update URL without reload
|
// Update URL without reload
|
||||||
const url = `{{ base_url }}storefront/account/messages/${conversationId}`;
|
const url = `{{ base_url }}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);
|
||||||
@@ -404,7 +404,7 @@ function shopMessages() {
|
|||||||
this.loadConversations();
|
this.loadConversations();
|
||||||
|
|
||||||
// Update URL
|
// Update URL
|
||||||
history.pushState({}, '', '{{ base_url }}storefront/account/messages');
|
history.pushState({}, '', '{{ base_url }}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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
|
<a href="{{ base_url }}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 }}storefront/account/orders" class="hover:text-primary">Orders</a>
|
<a href="{{ base_url }}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 }}storefront/account/orders"
|
<a href="{{ base_url }}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 }}storefront/account/messages"
|
<a href="{{ base_url }}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 }}storefront/account/orders"
|
<a href="{{ base_url }}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,7 +375,7 @@ 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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
@@ -501,7 +501,7 @@ 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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
|
|||||||
@@ -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 }}storefront/account/dashboard" class="hover:text-primary">My Account</a>
|
<a href="{{ base_url }}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 }}storefront/products"
|
<a href="{{ base_url }}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 }}storefront/account/orders/' + order.id"
|
<a :href="'{{ base_url }}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,7 +167,7 @@ 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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }}storefront/account/login?next=' + encodeURIComponent(window.location.pathname);
|
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error('Failed to load orders');
|
throw new Error('Failed to load orders');
|
||||||
|
|||||||
@@ -112,13 +112,19 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS.
|
|||||||
- Service account JSON key generated
|
- Service account JSON key generated
|
||||||
- Dependencies added to `requirements.txt`: `google-auth>=2.0.0`, `PyJWT>=2.0.0` (commit `d36783a`)
|
- Dependencies added to `requirements.txt`: `google-auth>=2.0.0`, `PyJWT>=2.0.0` (commit `d36783a`)
|
||||||
- Loyalty env vars added to `.env.example` and `docs/deployment/environment.md`
|
- Loyalty env vars added to `.env.example` and `docs/deployment/environment.md`
|
||||||
|
- `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` added to `app/core/config.py` Settings class
|
||||||
|
- **End-to-end integration wired:**
|
||||||
|
- Enrollment auto-creates Google Wallet class + object (`card_service` → `wallet_service.create_wallet_objects`)
|
||||||
|
- Stamp/points operations auto-sync to Google Wallet (`stamp_service`/`points_service` → `wallet_service.sync_card_to_wallets`)
|
||||||
|
- Storefront API returns wallet URLs (`GET /loyalty/card`, `POST /loyalty/enroll`)
|
||||||
|
- "Add to Google Wallet" button wired in storefront dashboard and enrollment success page (Alpine.js conditional rendering)
|
||||||
|
- Google Wallet is a platform-wide config (env vars only) — merchants don't need to configure anything
|
||||||
|
|
||||||
**Next steps:**
|
**Next steps:**
|
||||||
|
|
||||||
- [ ] Upload service account JSON to Hetzner server
|
- [ ] Upload service account JSON to Hetzner server
|
||||||
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` in production `.env`
|
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` in production `.env`
|
||||||
- [ ] Restart app and test end-to-end: enroll → add pass → stamp → verify pass updates
|
- [ ] Restart app and test end-to-end: enroll → add pass → stamp → verify pass updates
|
||||||
- [ ] Wire "Add to Google Wallet" button into storefront enrollment success page
|
|
||||||
- [ ] Submit for Google production approval when ready
|
- [ ] Submit for Google production approval when ready
|
||||||
- [ ] Apple Wallet setup (APNs push, certificates, pass images)
|
- [ ] Apple Wallet setup (APNs push, certificates, pass images)
|
||||||
|
|
||||||
@@ -1872,7 +1878,21 @@ Restart the application:
|
|||||||
docker compose --profile full up -d --build
|
docker compose --profile full up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 25.6 Verify Configuration
|
### 25.6 Platform-Level Configuration
|
||||||
|
|
||||||
|
Google Wallet is a **platform-wide setting** — all merchants on the platform share the same Issuer ID and service account. Merchants don't need to configure anything; wallet integration activates automatically when the env vars are set.
|
||||||
|
|
||||||
|
The two required env vars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In production .env
|
||||||
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
||||||
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json
|
||||||
|
```
|
||||||
|
|
||||||
|
When both are set, every loyalty program on the platform automatically gets Google Wallet support: enrollment creates wallet passes, stamp/points operations sync to passes, and the storefront shows "Add to Google Wallet" buttons.
|
||||||
|
|
||||||
|
### 25.7 Verify Configuration
|
||||||
|
|
||||||
Check the API health and wallet service status:
|
Check the API health and wallet service status:
|
||||||
|
|
||||||
@@ -1880,12 +1900,11 @@ Check the API health and wallet service status:
|
|||||||
# Check the app logs for wallet service initialization
|
# Check the app logs for wallet service initialization
|
||||||
docker compose --profile full logs api | grep -i "wallet\|loyalty"
|
docker compose --profile full logs api | grep -i "wallet\|loyalty"
|
||||||
|
|
||||||
# Test via API — create a program and enroll a customer, then check the response
|
# Test via API — enroll a customer and check the response for wallet URLs
|
||||||
# for google_object_id and google Wallet URL fields
|
|
||||||
curl -s https://api.wizard.lu/health | python3 -m json.tool
|
curl -s https://api.wizard.lu/health | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
### 25.7 Testing Google Wallet Passes
|
### 25.8 Testing Google Wallet Passes
|
||||||
|
|
||||||
Google provides a **demo mode** — passes work in test without full production approval:
|
Google provides a **demo mode** — passes work in test without full production approval:
|
||||||
|
|
||||||
@@ -1895,20 +1914,21 @@ Google provides a **demo mode** — passes work in test without full production
|
|||||||
|
|
||||||
**End-to-end test flow:**
|
**End-to-end test flow:**
|
||||||
|
|
||||||
1. Create a loyalty program via the store panel
|
1. Create a loyalty program via the store panel and set the Google Wallet Issuer ID in Settings → Digital Wallet
|
||||||
2. Enroll a customer (via store or storefront self-enrollment)
|
2. Enroll a customer (via store or storefront self-enrollment)
|
||||||
3. The API returns a Google Wallet save URL
|
- The system automatically creates a Google Wallet `LoyaltyClass` (for the program) and `LoyaltyObject` (for the card)
|
||||||
4. Open the URL on an Android device — the pass is added to Google Wallet
|
3. Open the storefront loyalty dashboard — the "Add to Google Wallet" button appears
|
||||||
5. Add a stamp or points — the pass in Google Wallet auto-updates
|
4. Click the button (or open the URL on an Android device) — the pass is added to Google Wallet
|
||||||
|
5. Add a stamp or points — the pass in Google Wallet auto-updates (no push needed, Google syncs)
|
||||||
|
|
||||||
### 25.8 Local Development Setup
|
### 25.9 Local Development Setup
|
||||||
|
|
||||||
You can test the full Google Wallet integration from your local machine:
|
You can test the full Google Wallet integration from your local machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# In your local .env (or export directly)
|
# In your local .env
|
||||||
export LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
||||||
export LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/orion-488322-xxxxx.json
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/orion-488322-xxxxx.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The `GoogleWalletService` calls Google's REST API directly over HTTPS — no special network configuration needed. The same service account JSON works on both local and server environments.
|
The `GoogleWalletService` calls Google's REST API directly over HTTPS — no special network configuration needed. The same service account JSON works on both local and server environments.
|
||||||
@@ -1917,24 +1937,27 @@ The `GoogleWalletService` calls Google's REST API directly over HTTPS — no spe
|
|||||||
|
|
||||||
- [x] Service account JSON downloaded and path set in env
|
- [x] Service account JSON downloaded and path set in env
|
||||||
- [x] `LOYALTY_GOOGLE_ISSUER_ID` set in env
|
- [x] `LOYALTY_GOOGLE_ISSUER_ID` set in env
|
||||||
- [ ] Start the app locally: `uvicorn app.main:app --reload`
|
- [ ] Start the app locally: `python3 -m uvicorn main:app --reload`
|
||||||
- [ ] Create a loyalty program → verify `google_class_id` is set
|
- [ ] Enroll a customer → check logs for "Created Google Wallet class" and "Created Google Wallet object"
|
||||||
- [ ] Enroll a customer → verify `google_object_id` is set
|
- [ ] Open storefront dashboard → "Add to Google Wallet" button should appear
|
||||||
- [ ] Call `get_save_url()` → open the URL on Android to add pass
|
- [ ] Open the wallet URL on Android → pass added to Google Wallet
|
||||||
- [ ] Add stamps → verify pass updates in Google Wallet
|
- [ ] Add stamps → check logs for "Updated Google Wallet object", verify pass updates
|
||||||
|
|
||||||
### 25.9 How It Works (Architecture)
|
### 25.10 How It Works (Architecture)
|
||||||
|
|
||||||
|
The integration is fully automatic — no manual API calls needed after initial setup.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
||||||
│ Merchant │────▶│ Orion API │────▶│ Google Wallet API │
|
│ Merchant │────▶│ Orion API │────▶│ Google Wallet API │
|
||||||
│ creates │ │ │ │ │
|
│ sets issuer │ │ │ │ │
|
||||||
│ program │ │ create_class │ │ POST /loyaltyClass │
|
│ ID in UI │ │ │ │ │
|
||||||
└─────────────┘ └──────────────┘ └─────────────────────┘
|
└─────────────┘ └──────────────┘ └─────────────────────┘
|
||||||
|
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
||||||
│ Customer │────▶│ Orion API │────▶│ Google Wallet API │
|
│ Customer │────▶│ Orion API │────▶│ Google Wallet API │
|
||||||
│ enrolls │ │ │ │ │
|
│ enrolls │ │ │ │ │
|
||||||
|
│ │ │create_class +│ │ POST /loyaltyClass │
|
||||||
│ │ │create_object │ │ POST /loyaltyObject │
|
│ │ │create_object │ │ POST /loyaltyObject │
|
||||||
│ │◀────│ save_url │ │ │
|
│ │◀────│ save_url │ │ │
|
||||||
│ │ └──────────────┘ └─────────────────────┘
|
│ │ └──────────────┘ └─────────────────────┘
|
||||||
@@ -1944,22 +1967,30 @@ The `GoogleWalletService` calls Google's REST API directly over HTTPS — no spe
|
|||||||
|
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
||||||
│ Staff adds │────▶│ Orion API │────▶│ Google Wallet API │
|
│ Staff adds │────▶│ Orion API │────▶│ Google Wallet API │
|
||||||
│ stamp │ │ │ │ │
|
│ stamp/pts │ │ │ │ │
|
||||||
│ │ │update_object │ │ PATCH /loyaltyObject│
|
│ │ │update_object │ │ PATCH /loyaltyObject│
|
||||||
└─────────────┘ └──────────────┘ └─────────────────────┘
|
└─────────────┘ └──────────────┘ └─────────────────────┘
|
||||||
Pass auto-updates on
|
Pass auto-updates on
|
||||||
customer's phone
|
customer's phone
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Automatic triggers:**
|
||||||
|
|
||||||
|
| Event | Wallet Action | Service Call |
|
||||||
|
|-------|---------------|--------------|
|
||||||
|
| Customer enrolls | Create class (if first) + create object | `wallet_service.create_wallet_objects()` |
|
||||||
|
| Stamp added/redeemed/voided | Update object with new balance | `wallet_service.sync_card_to_wallets()` |
|
||||||
|
| Points earned/redeemed/voided/adjusted | Update object with new balance | `wallet_service.sync_card_to_wallets()` |
|
||||||
|
| Customer opens dashboard | Generate save URL (JWT, 1h expiry) | `wallet_service.get_add_to_wallet_urls()` |
|
||||||
|
|
||||||
No push notifications needed — Google syncs object changes automatically.
|
No push notifications needed — Google syncs object changes automatically.
|
||||||
|
|
||||||
### 25.10 Next Steps
|
### 25.11 Next Steps
|
||||||
|
|
||||||
After Google Wallet is verified working:
|
After Google Wallet is verified working:
|
||||||
|
|
||||||
1. **Wire "Add to Google Wallet" button** into the storefront enrollment success page and card dashboard
|
1. **Submit for Google production approval** — required before non-test users can add passes
|
||||||
2. **Submit for Google production approval** — required before non-test users can add passes
|
2. **Apple Wallet** — separate setup requiring Apple Developer account, APNs certificates, and pass signing certificates (see [Loyalty Module docs](../modules/loyalty.md#apple-wallet))
|
||||||
3. **Apple Wallet** — separate setup requiring Apple Developer account, APNs certificates, and pass signing certificates (see [Loyalty Module docs](../modules/loyalty.md#apple-wallet))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -167,14 +167,19 @@ Maximum stamps per card per day (default: 5).
|
|||||||
|
|
||||||
### Google Wallet
|
### Google Wallet
|
||||||
|
|
||||||
Architecture: **Server-side storage with API updates**
|
Architecture: **Server-side storage with automatic API updates**
|
||||||
|
|
||||||
1. Program created → Create `LoyaltyClass` via Google API
|
All wallet operations are triggered automatically — no manual API calls needed:
|
||||||
2. Customer enrolls → Create `LoyaltyObject` via Google API
|
|
||||||
3. Stamp/points change → `PATCH` the object
|
|
||||||
4. Generate JWT for "Add to Wallet" button
|
|
||||||
|
|
||||||
No device registration needed - Google syncs automatically.
|
| Event | Wallet Action | Trigger |
|
||||||
|
|-------|---------------|---------|
|
||||||
|
| Customer enrolls | Create `LoyaltyClass` (first time) + `LoyaltyObject` | `card_service.enroll_customer()` → `wallet_service.create_wallet_objects()` |
|
||||||
|
| Stamp/points change | `PATCH` the object with new balance | `stamp_service`/`points_service` → `wallet_service.sync_card_to_wallets()` |
|
||||||
|
| Customer views dashboard | Generate JWT "Add to Wallet" URL (1h expiry) | `GET /storefront/loyalty/card` → `wallet_service.get_add_to_wallet_urls()` |
|
||||||
|
|
||||||
|
**Setup:** Configure `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` env vars. This is a platform-wide setting — all merchants automatically get Google Wallet support. See [Google Wallet Setup](../deployment/hetzner-server-setup.md#step-25-google-wallet-integration) for full instructions.
|
||||||
|
|
||||||
|
No device registration needed — Google syncs automatically.
|
||||||
|
|
||||||
### Apple Wallet
|
### Apple Wallet
|
||||||
|
|
||||||
|
|||||||
140
docs/proposals/google-wallet-local-testing.md
Normal file
140
docs/proposals/google-wallet-local-testing.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Google Wallet Local Testing — Step-by-Step
|
||||||
|
|
||||||
|
All code wiring is complete. This guide walks through end-to-end local testing.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` must have:
|
||||||
|
|
||||||
|
```
|
||||||
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
||||||
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/home/samir/Documents/PycharmProjects/letzshop-product-import/orion-488322-2232195cbb62.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
No startup errors expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Log into the FASHIONHUB store panel
|
||||||
|
|
||||||
|
URL: <http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login>
|
||||||
|
|
||||||
|
Log in with store credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Verify a loyalty program exists
|
||||||
|
|
||||||
|
In the store dashboard, check that an active loyalty program exists with stamps or points enabled.
|
||||||
|
|
||||||
|
If none exists, create one from Settings > Loyalty:
|
||||||
|
|
||||||
|
- **Name**: e.g. "Fashion Rewards"
|
||||||
|
- **Stamps target** or **Points mode**: enable at least one
|
||||||
|
- **Welcome bonus points**: optional, set to e.g. 50 to test points on enrollment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Enroll a test customer
|
||||||
|
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
### Option A — Staff enrollment (store panel)
|
||||||
|
|
||||||
|
In the store dashboard, go to Loyalty, use "Enroll Customer" by email or customer ID.
|
||||||
|
|
||||||
|
Watch terminal for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Created Google Wallet class: <class_id>
|
||||||
|
Created Google Wallet object: <object_id>
|
||||||
|
Enrolled customer X in merchant Y loyalty program
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B — Customer self-enrollment (storefront)
|
||||||
|
|
||||||
|
1. Go to: <http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/>
|
||||||
|
2. Log in as a customer (or create an account)
|
||||||
|
3. Navigate to Loyalty > "Join Now"
|
||||||
|
4. After enrollment, you land on the **enroll-success** page
|
||||||
|
5. Watch terminal for the same Google Wallet creation logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Verify DB records
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Program should have google_class_id populated
|
||||||
|
SELECT id, name, google_issuer_id, google_class_id FROM loyalty_programs;
|
||||||
|
|
||||||
|
-- Card should have google_object_id populated
|
||||||
|
SELECT id, card_number, google_object_id, google_object_jwt
|
||||||
|
FROM loyalty_cards WHERE customer_id = <your_customer_id>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `google_class_id` and `google_object_id` should be non-null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Test the storefront dashboard (wallet button)
|
||||||
|
|
||||||
|
1. Go to: <http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/>
|
||||||
|
2. Log in as the enrolled customer
|
||||||
|
3. Go to **Account > My Loyalty**
|
||||||
|
4. Click **"Show Card"** — a modal appears
|
||||||
|
5. The **"Add to Google Wallet"** button (blue) should be visible
|
||||||
|
6. Click it — opens `https://pay.google.com/gp/v/save/...` in a new tab
|
||||||
|
|
||||||
|
If the button doesn't appear, check browser devtools > Network > `GET /storefront/loyalty/card` response and look at `wallet_urls.google_wallet_url`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Test stamp/points sync
|
||||||
|
|
||||||
|
From the store panel (<http://localhost:8000/platforms/loyalty/store/FASHIONHUB/>), add a stamp or earn points for the enrolled customer's card.
|
||||||
|
|
||||||
|
Watch terminal for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Updated Google Wallet object for card <card_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
This confirms the wallet pass updates on the customer's phone in real time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7: Verify the save URL works
|
||||||
|
|
||||||
|
The "Add to Google Wallet" URL is JWT-signed. Behaviour:
|
||||||
|
|
||||||
|
- **Demo/test mode** (before Google approves your issuer): preview page with unverified issuer warning — this is normal
|
||||||
|
- **Android**: pass gets added to Google Wallet app
|
||||||
|
- **Desktop**: Google shows a "Send to phone" option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
|---------|-------|
|
||||||
|
| No wallet logs on enrollment | Verify `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` are set in `.env` |
|
||||||
|
| "Google Wallet not configured" in logs | Service account JSON file path is wrong or file is unreadable |
|
||||||
|
| Button doesn't appear on dashboard | Check `GET /storefront/loyalty/card` response in devtools — `wallet_urls` should have a URL |
|
||||||
|
| 403 from Google API | Service account doesn't have Wallet API permissions, or issuer ID mismatch |
|
||||||
|
| JWT URL opens but shows error | Issuer account may not be approved yet — normal for testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key URLs
|
||||||
|
|
||||||
|
| Panel | URL |
|
||||||
|
|-------|-----|
|
||||||
|
| Store panel (FASHIONHUB) | <http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login> |
|
||||||
|
| Storefront | <http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/> |
|
||||||
|
| Admin panel | <http://localhost:8000/admin/login> |
|
||||||
@@ -319,10 +319,12 @@ class PlatformContextMiddleware:
|
|||||||
|
|
||||||
path = scope["path"]
|
path = scope["path"]
|
||||||
host = ""
|
host = ""
|
||||||
|
referer = ""
|
||||||
for header_name, header_value in scope.get("headers", []):
|
for header_name, header_value in scope.get("headers", []):
|
||||||
if header_name == b"host":
|
if header_name == b"host":
|
||||||
host = header_value.decode("utf-8")
|
host = header_value.decode("utf-8")
|
||||||
break
|
elif header_name == b"referer":
|
||||||
|
referer = header_value.decode("utf-8")
|
||||||
|
|
||||||
# Skip for static files
|
# Skip for static files
|
||||||
if self._is_static_file(path):
|
if self._is_static_file(path):
|
||||||
@@ -354,6 +356,23 @@ class PlatformContextMiddleware:
|
|||||||
# Detect platform context
|
# Detect platform context
|
||||||
platform_context = self._detect_platform_context(path, host)
|
platform_context = self._detect_platform_context(path, host)
|
||||||
|
|
||||||
|
# For storefront API requests on localhost, the path doesn't contain
|
||||||
|
# /platforms/{code}/, so extract platform from the Referer header instead.
|
||||||
|
# e.g., Referer: http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/...
|
||||||
|
host_without_port = host.split(":")[0] if ":" in host else host
|
||||||
|
if (
|
||||||
|
host_without_port in _LOCAL_HOSTS
|
||||||
|
and path.startswith("/api/v1/storefront/")
|
||||||
|
and referer
|
||||||
|
and platform_context
|
||||||
|
and platform_context.get("detection_method") == "default"
|
||||||
|
):
|
||||||
|
referer_platform = self._extract_platform_from_referer(referer)
|
||||||
|
if referer_platform:
|
||||||
|
# Keep the original API path — don't rewrite to the Referer's path
|
||||||
|
referer_platform["clean_path"] = path
|
||||||
|
platform_context = referer_platform
|
||||||
|
|
||||||
if platform_context:
|
if platform_context:
|
||||||
db_gen = get_db()
|
db_gen = get_db()
|
||||||
db = next(db_gen)
|
db = next(db_gen)
|
||||||
@@ -488,6 +507,43 @@ class PlatformContextMiddleware:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_platform_from_referer(referer: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Extract platform context from Referer header.
|
||||||
|
|
||||||
|
Used for storefront API requests on localhost where the API path
|
||||||
|
doesn't contain /platforms/{code}/ but the Referer does.
|
||||||
|
|
||||||
|
e.g., Referer: http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/...
|
||||||
|
→ platform_code = "loyalty"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(referer)
|
||||||
|
referer_path = parsed.path or ""
|
||||||
|
|
||||||
|
if referer_path.startswith("/platforms/"):
|
||||||
|
path_after = referer_path[11:] # Remove "/platforms/"
|
||||||
|
parts = path_after.split("/", 1)
|
||||||
|
platform_code = parts[0].lower()
|
||||||
|
if platform_code:
|
||||||
|
logger.debug(
|
||||||
|
f"[PLATFORM] Extracted platform from Referer: {platform_code}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"path_prefix": platform_code,
|
||||||
|
"detection_method": "path",
|
||||||
|
"host": parsed.hostname or "",
|
||||||
|
"original_path": referer_path,
|
||||||
|
"clean_path": "/" + parts[1] if len(parts) > 1 and parts[1] else "/",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[PLATFORM] Failed to extract platform from Referer: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _is_static_file(self, path: str) -> bool:
|
def _is_static_file(self, path: str) -> bool:
|
||||||
"""Check if path is for static files."""
|
"""Check if path is for static files."""
|
||||||
path_lower = path.lower()
|
path_lower = path.lower()
|
||||||
|
|||||||
@@ -444,8 +444,42 @@ class StoreContextMiddleware(BaseHTTPMiddleware):
|
|||||||
request.state.clean_path = request.url.path
|
request.state.clean_path = request.url.path
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Skip store detection for API routes (admin API, store API have store_id in URL)
|
# For API routes: skip most, but handle storefront API via Referer
|
||||||
if StoreContextManager.is_api_request(request):
|
if StoreContextManager.is_api_request(request):
|
||||||
|
# Storefront API requests need store context from the Referer header
|
||||||
|
# (the page URL contains the store code, e.g. /storefront/FASHIONHUB/...)
|
||||||
|
if request.url.path.startswith("/api/v1/storefront/"):
|
||||||
|
referer_context = StoreContextManager.extract_store_from_referer(request)
|
||||||
|
if referer_context:
|
||||||
|
db_gen = get_db()
|
||||||
|
db = next(db_gen)
|
||||||
|
try:
|
||||||
|
store = StoreContextManager.get_store_from_context(db, referer_context)
|
||||||
|
request.state.store = store
|
||||||
|
request.state.store_context = referer_context
|
||||||
|
request.state.clean_path = request.url.path
|
||||||
|
if store:
|
||||||
|
logger.debug(
|
||||||
|
"[STORE] Store detected for storefront API via Referer",
|
||||||
|
extra={
|
||||||
|
"store_id": store.id,
|
||||||
|
"store_name": store.name,
|
||||||
|
"path": request.url.path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return await call_next(request)
|
||||||
|
logger.debug(
|
||||||
|
f"[STORE] No Referer store context for storefront API: {request.url.path}",
|
||||||
|
extra={"path": request.url.path},
|
||||||
|
)
|
||||||
|
request.state.store = None
|
||||||
|
request.state.store_context = None
|
||||||
|
request.state.clean_path = request.url.path
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Non-storefront API routes: skip store detection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[STORE] Skipping store detection for non-storefront API: {request.url.path}",
|
f"[STORE] Skipping store detection for non-storefront API: {request.url.path}",
|
||||||
extra={"path": request.url.path, "reason": "api"},
|
extra={"path": request.url.path, "reason": "api"},
|
||||||
|
|||||||
@@ -1382,9 +1382,10 @@ def print_summary(db: Session):
|
|||||||
print(f" {store.name} ({store.store_code}):")
|
print(f" {store.name} ({store.store_code}):")
|
||||||
if platform_codes:
|
if platform_codes:
|
||||||
for pc in platform_codes:
|
for pc in platform_codes:
|
||||||
print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
|
print(f" [{pc}] Storefront: {base}/platforms/{pc}/storefront/{store.store_code}/")
|
||||||
print(f" [{pc}] Dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
|
print(f" [{pc}] Customer login: {base}/platforms/{pc}/storefront/{store.store_code}/account/login")
|
||||||
print(f" [{pc}] Login: {base}/platforms/{pc}/store/{store.store_code}/login")
|
print(f" [{pc}] Store dashboard: {base}/platforms/{pc}/store/{store.store_code}/")
|
||||||
|
print(f" [{pc}] Staff login: {base}/platforms/{pc}/store/{store.store_code}/login")
|
||||||
else:
|
else:
|
||||||
print(" (!) No platform assigned")
|
print(" (!) No platform assigned")
|
||||||
print()
|
print()
|
||||||
@@ -1397,7 +1398,7 @@ def print_summary(db: Session):
|
|||||||
print(f" 3. Merchant panel: {base}/merchants/login")
|
print(f" 3. Merchant panel: {base}/merchants/login")
|
||||||
print(f" 4. Store panel: {base}/platforms/oms/store/WIZATECH/login")
|
print(f" 4. Store panel: {base}/platforms/oms/store/WIZATECH/login")
|
||||||
print(f" 5. Storefront: {base}/platforms/oms/storefront/WIZATECH/")
|
print(f" 5. Storefront: {base}/platforms/oms/storefront/WIZATECH/")
|
||||||
print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account")
|
print(f" 6. Customer login: {base}/platforms/oms/storefront/WIZATECH/account/login")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
44
tests/unit/api/test_storefront_auth.py
Normal file
44
tests/unit/api/test_storefront_auth.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# tests/unit/api/test_storefront_auth.py
|
||||||
|
"""
|
||||||
|
Unit tests for storefront auth cookie handling.
|
||||||
|
|
||||||
|
Tests that customer_token cookie is set with path='/' so it works
|
||||||
|
across all URL patterns (dev mode with /platforms/ prefix, prod mode with subdomains).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCustomerTokenCookiePath:
|
||||||
|
"""Verify cookie path is set correctly for cross-routing compatibility."""
|
||||||
|
|
||||||
|
def test_login_sets_cookie_with_root_path(self):
|
||||||
|
"""
|
||||||
|
The customer_token cookie must use path='/' to work with all URL patterns.
|
||||||
|
|
||||||
|
Previously the cookie path was calculated as '/storefront/{subdomain}/storefront'
|
||||||
|
which didn't match the actual page URLs (/platforms/{code}/storefront/{store_code}/...).
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.modules.customers.routes.api.storefront import customer_login
|
||||||
|
|
||||||
|
source = inspect.getsource(customer_login)
|
||||||
|
# Verify the cookie is set with path="/"
|
||||||
|
assert 'path="/"' in source or "path='/'" in source, (
|
||||||
|
"customer_login must set cookie with path='/'. "
|
||||||
|
"Other paths like '/storefront' or '/storefront/{subdomain}/storefront' "
|
||||||
|
"don't match dev mode URLs (/platforms/{code}/storefront/{store_code}/...)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_logout_deletes_cookie_with_root_path(self):
|
||||||
|
"""The customer_token cookie must be deleted with path='/' to match the set path."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.modules.customers.routes.api.storefront import customer_logout
|
||||||
|
|
||||||
|
source = inspect.getsource(customer_logout)
|
||||||
|
assert 'path="/"' in source or "path='/'" in source, (
|
||||||
|
"customer_logout must delete cookie with path='/' to match how it was set"
|
||||||
|
)
|
||||||
@@ -29,7 +29,9 @@ class TestFrontendTypeMiddleware:
|
|||||||
request = Mock(spec=Request)
|
request = Mock(spec=Request)
|
||||||
request.url = Mock(path="/admin/dashboard")
|
request.url = Mock(path="/admin/dashboard")
|
||||||
request.headers = {"host": "localhost"}
|
request.headers = {"host": "localhost"}
|
||||||
request.state = Mock(clean_path="/admin/dashboard", store=None)
|
request.state = Mock(spec=[])
|
||||||
|
request.state.platform_clean_path = "/admin/dashboard"
|
||||||
|
request.state.store = None
|
||||||
|
|
||||||
call_next = AsyncMock(return_value=Mock())
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
@@ -47,7 +49,9 @@ class TestFrontendTypeMiddleware:
|
|||||||
request = Mock(spec=Request)
|
request = Mock(spec=Request)
|
||||||
request.url = Mock(path="/store/settings")
|
request.url = Mock(path="/store/settings")
|
||||||
request.headers = {"host": "localhost"}
|
request.headers = {"host": "localhost"}
|
||||||
request.state = Mock(clean_path="/store/settings", store=None)
|
request.state = Mock(spec=[])
|
||||||
|
request.state.platform_clean_path = "/store/settings"
|
||||||
|
request.state.store = None
|
||||||
|
|
||||||
call_next = AsyncMock(return_value=Mock())
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
@@ -64,7 +68,9 @@ class TestFrontendTypeMiddleware:
|
|||||||
request = Mock(spec=Request)
|
request = Mock(spec=Request)
|
||||||
request.url = Mock(path="/storefront/products")
|
request.url = Mock(path="/storefront/products")
|
||||||
request.headers = {"host": "localhost"}
|
request.headers = {"host": "localhost"}
|
||||||
request.state = Mock(clean_path="/storefront/products", store=None)
|
request.state = Mock(spec=[])
|
||||||
|
request.state.platform_clean_path = "/storefront/products"
|
||||||
|
request.state.store = None
|
||||||
|
|
||||||
call_next = AsyncMock(return_value=Mock())
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
@@ -83,7 +89,9 @@ class TestFrontendTypeMiddleware:
|
|||||||
request.headers = {"host": "orion.omsflow.lu"}
|
request.headers = {"host": "orion.omsflow.lu"}
|
||||||
mock_store = Mock()
|
mock_store = Mock()
|
||||||
mock_store.name = "Test Store"
|
mock_store.name = "Test Store"
|
||||||
request.state = Mock(clean_path="/products", store=mock_store)
|
request.state = Mock(spec=[])
|
||||||
|
request.state.platform_clean_path = "/products"
|
||||||
|
request.state.store = mock_store
|
||||||
|
|
||||||
call_next = AsyncMock(return_value=Mock())
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
@@ -100,7 +108,9 @@ class TestFrontendTypeMiddleware:
|
|||||||
request = Mock(spec=Request)
|
request = Mock(spec=Request)
|
||||||
request.url = Mock(path="/pricing")
|
request.url = Mock(path="/pricing")
|
||||||
request.headers = {"host": "localhost"}
|
request.headers = {"host": "localhost"}
|
||||||
request.state = Mock(clean_path="/pricing", store=None)
|
request.state = Mock(spec=[])
|
||||||
|
request.state.platform_clean_path = "/pricing"
|
||||||
|
request.state.store = None
|
||||||
|
|
||||||
call_next = AsyncMock(return_value=Mock())
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
@@ -127,21 +137,23 @@ class TestFrontendTypeMiddleware:
|
|||||||
assert response is expected_response
|
assert response is expected_response
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_middleware_uses_clean_path_when_available(self):
|
async def test_middleware_uses_platform_clean_path_when_available(self):
|
||||||
"""Test middleware uses clean_path when available."""
|
"""Test middleware uses platform_clean_path when available."""
|
||||||
middleware = FrontendTypeMiddleware(app=None)
|
middleware = FrontendTypeMiddleware(app=None)
|
||||||
|
|
||||||
request = Mock(spec=Request)
|
request = Mock(spec=Request)
|
||||||
request.url = Mock(path="/stores/orion/store/settings")
|
request.url = Mock(path="/platforms/main/store/settings")
|
||||||
request.headers = {"host": "localhost"}
|
request.headers = {"host": "localhost"}
|
||||||
# clean_path shows the rewritten path
|
# platform_clean_path shows the path with platform prefix stripped
|
||||||
request.state = Mock(clean_path="/store/settings", store=None)
|
request.state = Mock(spec=[])
|
||||||
|
request.state.platform_clean_path = "/store/settings"
|
||||||
|
request.state.store = None
|
||||||
|
|
||||||
call_next = AsyncMock(return_value=Mock())
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
await middleware.dispatch(request, call_next)
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
# Should detect as STORE based on clean_path
|
# Should detect as STORE based on platform_clean_path
|
||||||
assert request.state.frontend_type == FrontendType.STORE
|
assert request.state.frontend_type == FrontendType.STORE
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ class TestLanguageDispatchStore:
|
|||||||
mock_resolve.assert_called_once_with(
|
mock_resolve.assert_called_once_with(
|
||||||
user_preferred="de",
|
user_preferred="de",
|
||||||
store_dashboard="fr",
|
store_dashboard="fr",
|
||||||
|
cookie_language=None,
|
||||||
)
|
)
|
||||||
assert request.state.language == "de"
|
assert request.state.language == "de"
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ class TestLanguageDispatchStore:
|
|||||||
mock_resolve.assert_called_once_with(
|
mock_resolve.assert_called_once_with(
|
||||||
user_preferred=None,
|
user_preferred=None,
|
||||||
store_dashboard=None,
|
store_dashboard=None,
|
||||||
|
cookie_language=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +358,7 @@ class TestLanguageHelpers:
|
|||||||
max_age=60 * 60 * 24 * 365,
|
max_age=60 * 60 * 24 * 365,
|
||||||
httponly=False,
|
httponly=False,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
)
|
)
|
||||||
assert result is response
|
assert result is response
|
||||||
|
|
||||||
@@ -372,7 +375,7 @@ class TestLanguageHelpers:
|
|||||||
response = Mock(spec=Response)
|
response = Mock(spec=Response)
|
||||||
result = delete_language_cookie(response)
|
result = delete_language_cookie(response)
|
||||||
|
|
||||||
response.delete_cookie.assert_called_once_with(key="lang")
|
response.delete_cookie.assert_called_once_with(key="lang", path="/")
|
||||||
assert result is response
|
assert result is response
|
||||||
|
|
||||||
def test_get_user_language_from_token_with_pref(self):
|
def test_get_user_language_from_token_with_pref(self):
|
||||||
|
|||||||
@@ -995,3 +995,88 @@ class TestURLRoutingSummary:
|
|||||||
assert context["detection_method"] == "domain"
|
assert context["detection_method"] == "domain"
|
||||||
assert context["domain"] == "omsflow.lu"
|
assert context["domain"] == "omsflow.lu"
|
||||||
# clean_path not set for domain detection - uses original path
|
# clean_path not set for domain detection - uses original path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.middleware
|
||||||
|
class TestExtractPlatformFromReferer:
|
||||||
|
"""Tests for Referer-based platform detection for storefront API requests."""
|
||||||
|
|
||||||
|
def test_extract_platform_from_referer_with_platforms_prefix(self):
|
||||||
|
"""Extract platform code from Referer with /platforms/{code}/ path."""
|
||||||
|
middleware = PlatformContextMiddleware(app=None)
|
||||||
|
result = middleware._extract_platform_from_referer(
|
||||||
|
"http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login"
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["path_prefix"] == "loyalty"
|
||||||
|
assert result["detection_method"] == "path"
|
||||||
|
|
||||||
|
def test_extract_platform_from_referer_no_platforms_prefix(self):
|
||||||
|
"""Referer without /platforms/ returns None."""
|
||||||
|
middleware = PlatformContextMiddleware(app=None)
|
||||||
|
result = middleware._extract_platform_from_referer(
|
||||||
|
"http://localhost:8000/storefront/FASHIONHUB/products"
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_extract_platform_from_referer_empty_string(self):
|
||||||
|
"""Empty referer returns None."""
|
||||||
|
middleware = PlatformContextMiddleware(app=None)
|
||||||
|
result = middleware._extract_platform_from_referer("")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_extract_platform_from_referer_oms_platform(self):
|
||||||
|
"""Extract OMS platform from Referer."""
|
||||||
|
middleware = PlatformContextMiddleware(app=None)
|
||||||
|
result = middleware._extract_platform_from_referer(
|
||||||
|
"http://localhost:8000/platforms/oms/store/WIZATECH/dashboard"
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["path_prefix"] == "oms"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.middleware
|
||||||
|
class TestPlatformContextMiddlewareReferer:
|
||||||
|
"""Test PlatformContextMiddleware __call__ with Referer-based platform detection."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_storefront_api_uses_referer_platform(self):
|
||||||
|
"""Test storefront API requests on localhost extract platform from Referer."""
|
||||||
|
mock_app = AsyncMock()
|
||||||
|
middleware = PlatformContextMiddleware(app=mock_app)
|
||||||
|
|
||||||
|
mock_platform = Mock()
|
||||||
|
mock_platform.id = 3
|
||||||
|
mock_platform.code = "loyalty"
|
||||||
|
mock_platform.name = "Loyalty Platform"
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"path": "/api/v1/storefront/auth/login",
|
||||||
|
"headers": [
|
||||||
|
(b"host", b"localhost:8000"),
|
||||||
|
(b"referer", b"http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
receive = AsyncMock()
|
||||||
|
send = AsyncMock()
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"middleware.platform_context.get_db", return_value=iter([mock_db])
|
||||||
|
), patch.object(
|
||||||
|
PlatformContextManager,
|
||||||
|
"get_platform_from_context",
|
||||||
|
return_value=mock_platform,
|
||||||
|
):
|
||||||
|
await middleware(scope, receive, send)
|
||||||
|
|
||||||
|
# Platform should be detected from Referer
|
||||||
|
assert scope["state"]["platform"] is mock_platform
|
||||||
|
assert scope["state"]["platform_context"]["path_prefix"] == "loyalty"
|
||||||
|
# API path should NOT be rewritten to the Referer's path
|
||||||
|
assert scope["path"] == "/api/v1/storefront/auth/login"
|
||||||
|
|||||||
@@ -733,6 +733,115 @@ class TestStoreContextMiddleware:
|
|||||||
assert request.state.clean_path == "/random/path"
|
assert request.state.clean_path == "/random/path"
|
||||||
call_next.assert_called_once_with(request)
|
call_next.assert_called_once_with(request)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Storefront API Referer Tests
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_storefront_api_uses_referer(self):
|
||||||
|
"""Test storefront API requests get store context from Referer header."""
|
||||||
|
middleware = StoreContextMiddleware(app=None)
|
||||||
|
|
||||||
|
request = Mock(spec=Request)
|
||||||
|
request.headers = {
|
||||||
|
"host": "localhost",
|
||||||
|
"referer": "http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login",
|
||||||
|
}
|
||||||
|
request.url = Mock(path="/api/v1/storefront/auth/login")
|
||||||
|
request.state = Mock()
|
||||||
|
|
||||||
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
|
mock_store = Mock()
|
||||||
|
mock_store.id = 4
|
||||||
|
mock_store.name = "Fashion Hub"
|
||||||
|
mock_store.subdomain = "fashionhub"
|
||||||
|
|
||||||
|
referer_context = {
|
||||||
|
"subdomain": "FASHIONHUB",
|
||||||
|
"detection_method": "path",
|
||||||
|
"path_prefix": "/storefront/FASHIONHUB",
|
||||||
|
"full_prefix": "/storefront/",
|
||||||
|
"host": "localhost",
|
||||||
|
"referer": "http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/login",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
StoreContextManager,
|
||||||
|
"is_api_request",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
StoreContextManager,
|
||||||
|
"extract_store_from_referer",
|
||||||
|
return_value=referer_context,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
StoreContextManager,
|
||||||
|
"get_store_from_context",
|
||||||
|
return_value=mock_store,
|
||||||
|
),
|
||||||
|
patch("middleware.store_context.get_db", return_value=iter([mock_db])),
|
||||||
|
):
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
assert request.state.store is mock_store
|
||||||
|
assert request.state.store_context is referer_context
|
||||||
|
call_next.assert_called_once_with(request)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_storefront_api_no_referer(self):
|
||||||
|
"""Test storefront API request without Referer header sets store to None."""
|
||||||
|
middleware = StoreContextMiddleware(app=None)
|
||||||
|
|
||||||
|
request = Mock(spec=Request)
|
||||||
|
request.headers = {"host": "localhost"}
|
||||||
|
request.url = Mock(path="/api/v1/storefront/auth/login")
|
||||||
|
request.state = Mock()
|
||||||
|
|
||||||
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
StoreContextManager,
|
||||||
|
"is_api_request",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
StoreContextManager,
|
||||||
|
"extract_store_from_referer",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
assert request.state.store is None
|
||||||
|
assert request.state.store_context is None
|
||||||
|
call_next.assert_called_once_with(request)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_non_storefront_api_still_skips(self):
|
||||||
|
"""Test non-storefront API requests still skip store detection."""
|
||||||
|
middleware = StoreContextMiddleware(app=None)
|
||||||
|
|
||||||
|
request = Mock(spec=Request)
|
||||||
|
request.headers = {"host": "localhost"}
|
||||||
|
request.url = Mock(path="/api/v1/admin/stores")
|
||||||
|
request.state = Mock()
|
||||||
|
|
||||||
|
call_next = AsyncMock(return_value=Mock())
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
StoreContextManager, "is_api_request", return_value=True
|
||||||
|
):
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
assert request.state.store is None
|
||||||
|
call_next.assert_called_once_with(request)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# System Path Skipping Tests
|
# System Path Skipping Tests
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
125
tests/unit/test_exception_handler.py
Normal file
125
tests/unit/test_exception_handler.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# tests/unit/test_exception_handler.py
|
||||||
|
"""
|
||||||
|
Unit tests for exception handler login redirect.
|
||||||
|
|
||||||
|
Tests the _redirect_to_login function to ensure correct URL generation
|
||||||
|
for storefront contexts, preventing double 'storefront/' in redirects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.exceptions.handler import _redirect_to_login
|
||||||
|
from app.modules.enums import FrontendType
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(
|
||||||
|
path="/storefront/FASHIONHUB/account/dashboard",
|
||||||
|
frontend_type=FrontendType.STOREFRONT,
|
||||||
|
store=None,
|
||||||
|
store_context=None,
|
||||||
|
platform=None,
|
||||||
|
platform_original_path=None,
|
||||||
|
):
|
||||||
|
request = Mock(spec=Request)
|
||||||
|
request.url = Mock(path=path)
|
||||||
|
request.method = "GET"
|
||||||
|
request.headers = {"accept": "text/html"}
|
||||||
|
request.state = Mock()
|
||||||
|
request.state.frontend_type = frontend_type
|
||||||
|
request.state.store = store
|
||||||
|
request.state.store_context = store_context
|
||||||
|
request.state.platform = platform
|
||||||
|
request.state.platform_original_path = platform_original_path
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store(store_code="FASHIONHUB", subdomain="fashionhub"):
|
||||||
|
store = Mock()
|
||||||
|
store.store_code = store_code
|
||||||
|
store.subdomain = subdomain
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _make_platform(code="loyalty"):
|
||||||
|
platform = Mock()
|
||||||
|
platform.code = code
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestRedirectToLogin:
|
||||||
|
"""Tests for _redirect_to_login storefront URL generation."""
|
||||||
|
|
||||||
|
def test_storefront_redirect_dev_mode_no_double_storefront(self):
|
||||||
|
"""Dev mode redirect should not have double 'storefront/' in URL."""
|
||||||
|
store = _make_store(store_code="FASHIONHUB")
|
||||||
|
platform = _make_platform(code="loyalty")
|
||||||
|
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
|
||||||
|
|
||||||
|
request = _make_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
platform=platform,
|
||||||
|
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/account/dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _redirect_to_login(request)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
location = response.headers["location"]
|
||||||
|
assert location == "/platforms/loyalty/storefront/FASHIONHUB/account/login"
|
||||||
|
assert "/storefront/storefront/" not in location
|
||||||
|
|
||||||
|
def test_storefront_redirect_prod_mode(self):
|
||||||
|
"""Prod mode redirect should use /account/login."""
|
||||||
|
store = _make_store()
|
||||||
|
store_context = {"detection_method": "subdomain", "subdomain": "fashionhub"}
|
||||||
|
|
||||||
|
request = _make_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _redirect_to_login(request)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
location = response.headers["location"]
|
||||||
|
assert location == "/account/login"
|
||||||
|
|
||||||
|
def test_storefront_redirect_uses_store_code_not_subdomain(self):
|
||||||
|
"""Redirect URL should use store_code (uppercase), not subdomain (lowercase)."""
|
||||||
|
store = _make_store(store_code="FASHIONHUB", subdomain="fashionhub")
|
||||||
|
platform = _make_platform(code="loyalty")
|
||||||
|
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
|
||||||
|
|
||||||
|
request = _make_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
platform=platform,
|
||||||
|
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _redirect_to_login(request)
|
||||||
|
|
||||||
|
location = response.headers["location"]
|
||||||
|
assert "FASHIONHUB" in location
|
||||||
|
assert "fashionhub" not in location
|
||||||
|
|
||||||
|
def test_admin_redirect(self):
|
||||||
|
"""Admin requests redirect to /admin/login."""
|
||||||
|
request = _make_request(frontend_type=FrontendType.ADMIN)
|
||||||
|
|
||||||
|
response = _redirect_to_login(request)
|
||||||
|
|
||||||
|
assert response.headers["location"] == "/admin/login"
|
||||||
|
|
||||||
|
def test_merchant_redirect(self):
|
||||||
|
"""Merchant requests redirect to /merchants/login."""
|
||||||
|
request = _make_request(frontend_type=FrontendType.MERCHANT)
|
||||||
|
|
||||||
|
response = _redirect_to_login(request)
|
||||||
|
|
||||||
|
assert response.headers["location"] == "/merchants/login"
|
||||||
146
tests/unit/utils/test_page_context.py
Normal file
146
tests/unit/utils/test_page_context.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# tests/unit/utils/test_page_context.py
|
||||||
|
"""
|
||||||
|
Unit tests for page context utilities.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- base_url calculation for storefront pages
|
||||||
|
- Dev mode (path-based) with platform prefix
|
||||||
|
- Prod mode (subdomain/custom domain)
|
||||||
|
- Ensures no double 'storefront/' in URLs
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.modules.core.utils.page_context import get_storefront_context
|
||||||
|
|
||||||
|
|
||||||
|
def _make_storefront_request(
|
||||||
|
path="/storefront/TESTSHOP/products",
|
||||||
|
store=None,
|
||||||
|
store_context=None,
|
||||||
|
platform=None,
|
||||||
|
platform_original_path=None,
|
||||||
|
theme=None,
|
||||||
|
clean_path="/products",
|
||||||
|
language="en",
|
||||||
|
):
|
||||||
|
"""Create a mock request with storefront state."""
|
||||||
|
request = Mock(spec=Request)
|
||||||
|
request.url = Mock(path=path)
|
||||||
|
request.method = "GET"
|
||||||
|
request.headers = {"host": "localhost:8000"}
|
||||||
|
request.state = Mock()
|
||||||
|
request.state.store = store
|
||||||
|
request.state.store_context = store_context
|
||||||
|
request.state.clean_path = clean_path
|
||||||
|
request.state.platform = platform
|
||||||
|
request.state.platform_original_path = platform_original_path
|
||||||
|
request.state.theme = theme
|
||||||
|
request.state.language = language
|
||||||
|
request.state.subscription = None
|
||||||
|
request.state.subscription_tier = None
|
||||||
|
request.state.frontend_type = None
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store(store_code="TESTSHOP", subdomain="testshop"):
|
||||||
|
store = Mock()
|
||||||
|
store.store_code = store_code
|
||||||
|
store.subdomain = subdomain
|
||||||
|
store.id = 1
|
||||||
|
store.name = "Test Shop"
|
||||||
|
store.merchant_id = 1
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _make_platform(code="loyalty", platform_id=3):
|
||||||
|
platform = Mock()
|
||||||
|
platform.code = code
|
||||||
|
platform.id = platform_id
|
||||||
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBaseUrlCalculation:
|
||||||
|
"""Tests for base_url in storefront context -- prevents double 'storefront/' regression."""
|
||||||
|
|
||||||
|
def test_base_url_dev_mode_with_platform_prefix(self):
|
||||||
|
"""Dev mode: base_url includes /platforms/{code}/storefront/{store_code}/."""
|
||||||
|
store = _make_store(store_code="FASHIONHUB")
|
||||||
|
platform = _make_platform(code="loyalty")
|
||||||
|
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
|
||||||
|
|
||||||
|
request = _make_storefront_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
platform=platform,
|
||||||
|
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/products",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the module-level context building to avoid DB calls
|
||||||
|
# We only need to test the base_url logic, so patch what's needed
|
||||||
|
context = get_storefront_context(request)
|
||||||
|
|
||||||
|
assert context["base_url"] == "/platforms/loyalty/storefront/FASHIONHUB/"
|
||||||
|
# Verify no double storefront when building links
|
||||||
|
assert "/storefront/storefront/" not in context["base_url"]
|
||||||
|
|
||||||
|
def test_base_url_dev_mode_without_platform_prefix(self):
|
||||||
|
"""Dev mode without /platforms/ prefix: base_url = /storefront/{store_code}/."""
|
||||||
|
store = _make_store(store_code="TESTSHOP")
|
||||||
|
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
|
||||||
|
|
||||||
|
request = _make_storefront_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
platform=None,
|
||||||
|
platform_original_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = get_storefront_context(request)
|
||||||
|
|
||||||
|
assert context["base_url"] == "/storefront/TESTSHOP/"
|
||||||
|
assert "/storefront/storefront/" not in context["base_url"]
|
||||||
|
|
||||||
|
def test_base_url_prod_mode_subdomain(self):
|
||||||
|
"""Prod mode with subdomain: base_url = /."""
|
||||||
|
store = _make_store()
|
||||||
|
store_context = {"detection_method": "subdomain", "subdomain": "testshop"}
|
||||||
|
|
||||||
|
request = _make_storefront_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = get_storefront_context(request)
|
||||||
|
|
||||||
|
assert context["base_url"] == "/"
|
||||||
|
|
||||||
|
def test_base_url_no_store_context(self):
|
||||||
|
"""No store context: base_url = /."""
|
||||||
|
request = _make_storefront_request(store=None, store_context=None)
|
||||||
|
|
||||||
|
context = get_storefront_context(request)
|
||||||
|
|
||||||
|
assert context["base_url"] == "/"
|
||||||
|
|
||||||
|
def test_base_url_uses_store_code_not_subdomain(self):
|
||||||
|
"""base_url should use store_code (uppercase) not subdomain (lowercase)."""
|
||||||
|
store = _make_store(store_code="FASHIONHUB", subdomain="fashionhub")
|
||||||
|
platform = _make_platform(code="loyalty")
|
||||||
|
store_context = {"detection_method": "path", "full_prefix": "/storefront/"}
|
||||||
|
|
||||||
|
request = _make_storefront_request(
|
||||||
|
store=store,
|
||||||
|
store_context=store_context,
|
||||||
|
platform=platform,
|
||||||
|
platform_original_path="/platforms/loyalty/storefront/FASHIONHUB/products",
|
||||||
|
)
|
||||||
|
|
||||||
|
context = get_storefront_context(request)
|
||||||
|
|
||||||
|
assert "FASHIONHUB" in context["base_url"]
|
||||||
|
assert "fashionhub" not in context["base_url"]
|
||||||
Reference in New Issue
Block a user