Compare commits

...

2 Commits

Author SHA1 Message Date
169a774b9c feat(i18n): add reactive Alpine $t() magic and fix storefront language variable
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
- Register Alpine magic $t() for reactive translations in templates
- Dispatch i18n:ready event when translations load
- Fix base.html to use current_language instead of storefront_language

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 04:50:46 +01:00
ebbe6d62b8 refactor(dev-tools): replace _simulate_store_detection with real StoreContextManager method
Removed the duplicated store detection logic in the debug trace endpoint
and calls StoreContextManager._detect_store_from_host_and_path() directly,
which also picks up the platform domain guards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 04:49:59 +01:00
3 changed files with 40 additions and 65 deletions

View File

@@ -127,7 +127,7 @@ def trace_platform_resolution(
# ── Step 4: StoreContextMiddleware detection ── # ── Step 4: StoreContextMiddleware detection ──
# Simulate what StoreContextManager.detect_store_context() would see # Simulate what StoreContextManager.detect_store_context() would see
# It reads request.state.platform_clean_path which is set to clean_path # It reads request.state.platform_clean_path which is set to clean_path
store_context = _simulate_store_detection(clean_path, host) store_context = StoreContextManager._detect_store_from_host_and_path(host, clean_path, platform)
if store_context and platform: if store_context and platform:
store_context["_platform"] = platform store_context["_platform"] = platform
@@ -274,68 +274,6 @@ def trace_platform_resolution(
) )
def _simulate_store_detection(clean_path: str, host: str) -> dict | None:
"""
Simulate StoreContextManager.detect_store_context() for a given path/host.
Reproduces the same logic without needing a real Request object.
"""
from app.core.config import settings
from app.modules.tenancy.models import StoreDomain
host_without_port = host.split(":")[0] if ":" in host else host
# Method 1: Custom domain
platform_domain = getattr(settings, "platform_domain", "platform.com")
is_custom_domain = (
host_without_port
and not host_without_port.endswith(f".{platform_domain}")
and host_without_port != platform_domain
and host_without_port not in ["localhost", "127.0.0.1", "admin.localhost", "admin.127.0.0.1"]
and not host_without_port.startswith("admin.")
)
if is_custom_domain:
normalized_domain = StoreDomain.normalize_domain(host_without_port)
return {
"domain": normalized_domain,
"detection_method": "custom_domain",
"host": host_without_port,
"original_host": host,
}
# Method 2: Subdomain
if "." in host_without_port:
parts = host_without_port.split(".")
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
subdomain = parts[0]
return {
"subdomain": subdomain,
"detection_method": "subdomain",
"host": host_without_port,
}
# Method 3: Path-based
if clean_path.startswith(("/store/", "/stores/", "/storefront/")):
if clean_path.startswith("/storefront/"):
prefix_len = len("/storefront/")
elif clean_path.startswith("/stores/"):
prefix_len = len("/stores/")
else:
prefix_len = len("/store/")
path_parts = clean_path[prefix_len:].split("/")
if len(path_parts) >= 1 and path_parts[0]:
store_code = path_parts[0]
return {
"subdomain": store_code,
"detection_method": "path",
"path_prefix": clean_path[:prefix_len + len(store_code)],
"full_prefix": clean_path[:prefix_len],
"host": host_without_port,
}
return None
def _sanitize(d: dict | None) -> dict | None: def _sanitize(d: dict | None) -> dict | None:
"""Remove non-serializable objects from dict.""" """Remove non-serializable objects from dict."""

View File

@@ -377,7 +377,7 @@
// Wrapped in DOMContentLoaded so deferred i18n.js has loaded // Wrapped in DOMContentLoaded so deferred i18n.js has loaded
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
const modules = {% block i18n_modules %}[]{% endblock %}; const modules = {% block i18n_modules %}[]{% endblock %};
await I18n.init('{{ storefront_language | default("en") }}', modules); await I18n.init('{{ current_language | default("en") }}', modules);
}); });
</script> </script>

View File

@@ -11,9 +11,13 @@
* // Or load modules later * // Or load modules later
* await I18n.loadModule('inventory'); * await I18n.loadModule('inventory');
* *
* // Translate * // Translate (in JS)
* const message = I18n.t('catalog.messages.product_created'); * const message = I18n.t('catalog.messages.product_created');
* const withVars = I18n.t('common.welcome', { name: 'John' }); * const withVars = I18n.t('common.welcome', { name: 'John' });
*
* // Translate (in Alpine templates — reactive, updates when translations load)
* // <span x-text="$t('catalog.messages.product_created')"></span>
* // <span x-text="$t('common.welcome', {name: user})"></span>
*/ */
// Create logger for i18n module (with silent fallback if LogConfig not yet loaded) // Create logger for i18n module (with silent fallback if LogConfig not yet loaded)
@@ -43,6 +47,7 @@ const I18n = {
if (modules && modules.length > 0) { if (modules && modules.length > 0) {
await Promise.all(modules.map(m => this.loadModule(m))); await Promise.all(modules.map(m => this.loadModule(m)));
} }
this._notifyReady();
}, },
/** /**
@@ -159,12 +164,44 @@ const I18n = {
await this.loadModule(module); await this.loadModule(module);
} }
} }
this._notifyReady();
} catch (e) { } catch (e) {
i18nLog.error('Failed to change language:', e); i18nLog.error('Failed to change language:', e);
} }
},
/**
* Notify Alpine (and other listeners) that translations are ready.
* Bumps the Alpine store version so reactive $t() bindings re-evaluate.
*/
_notifyReady() {
this._ready = true;
// Bump Alpine store version if available (triggers $t() re-evaluation)
if (typeof Alpine !== 'undefined' && Alpine.store) {
const store = Alpine.store('i18n');
if (store) store._v++;
}
document.dispatchEvent(new CustomEvent('i18n:ready'));
} }
}; };
// Register Alpine magic $t() — reactive wrapper around I18n.t()
// Works across all frontends (merchant, admin, store, storefront)
document.addEventListener('alpine:init', () => {
// Store with a reactive version counter
Alpine.store('i18n', { _v: 0 });
// $t('key', {vars}) — use in x-text, x-html, or any Alpine expression
// Before translations load, returns the key (which matches fallback text);
// after _notifyReady() bumps _v, Alpine re-evaluates with loaded translations.
Alpine.magic('t', (el) => {
return (key, vars) => {
void Alpine.store('i18n')._v; // reactive dependency
return I18n.t(key, vars);
};
});
});
// Export for module usage // Export for module usage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.I18n = I18n; window.I18n = I18n;