+
@T("Order.Phone"): @billTo.PhoneNumber
-
+
@T("Order.Fax"): @billTo.FaxNumber
@@ -52,13 +52,13 @@
@Html.Raw(shipTo.FormattedAddress)
-
+
@T("Order.Email"): @shipTo.Email
-
+
@T("Order.Phone"): @shipTo.PhoneNumber
-
+
@T("Order.Fax"): @shipTo.FaxNumber
diff --git a/src/Smartstore.Web/Areas/Admin/Views/Product/_CreateOrUpdate.SpecificationAttributes.cshtml b/src/Smartstore.Web/Areas/Admin/Views/Product/_CreateOrUpdate.SpecificationAttributes.cshtml
index 01871c4673..dfa7c0b9a6 100644
--- a/src/Smartstore.Web/Areas/Admin/Views/Product/_CreateOrUpdate.SpecificationAttributes.cshtml
+++ b/src/Smartstore.Web/Areas/Admin/Views/Product/_CreateOrUpdate.SpecificationAttributes.cshtml
@@ -96,7 +96,7 @@
success: (response) => {
options.html('');
$.each(response, (id, option) => {
- options.append($('
').val(option.id).html(option.name));
+ options.append($(`
`));
});
options.trigger("change");
},
@@ -108,12 +108,11 @@
$(document).on("click", "#addProductSpec", () => {
const attributeId = $("#@(Html.IdFor(x => x.AddSpecificationAttributeModel.SpecificationAttributeId))").val();
- let elOption = $("#@(Html.IdFor(x => x.AddSpecificationAttributeModel.SpecificationAttributeOption))");
- const option = elOption.val();
const allowFiltering = $("#@(Html.IdFor(x => x.AddSpecificationAttributeModel.AllowFiltering))").val();
const showOnProductPage = $("#@(Html.IdFor(x => x.AddSpecificationAttributeModel.ShowOnProductPage))").val();
let elDisplayOrder = $("#@(Html.IdFor(x => x.AddSpecificationAttributeModel.DisplayOrder))");
const displayOrder = elDisplayOrder.val();
+ const $selectedOption = $("#@(Html.IdFor(x => x.AddSpecificationAttributeModel.SpecificationAttributeOption)) :selected");
$('#addProductSpec').attr('disabled', true);
@@ -123,7 +122,8 @@
url: "@(Url.Action("ProductSpecificationAttributeAdd", "Product"))",
data: {
"attributeId": attributeId,
- "attributeOption": option,
+ "optionId": parseInt($selectedOption.data('id')) || 0,
+ "optionValue": $selectedOption.val(),
"allowFiltering": allowFiltering,
"showOnProductPage": showOnProductPage,
"displayOrder": displayOrder,
diff --git a/src/Smartstore.Web/Controllers/IdentityController.cs b/src/Smartstore.Web/Controllers/IdentityController.cs
index 5d5d6f1658..cf7463183f 100644
--- a/src/Smartstore.Web/Controllers/IdentityController.cs
+++ b/src/Smartstore.Web/Controllers/IdentityController.cs
@@ -361,6 +361,12 @@ public async Task
AccountActivation(string token, string email)
return RedirectToRoute("Homepage");
}
+ if (await _userManager.IsEmailConfirmedAsync(customer))
+ {
+ ViewBag.ActivationResult = T("Account.AccountActivation.Activated");
+ return View();
+ }
+
// Validate token & set user to active.
var confirmed = await _userManager.ConfirmEmailAsync(customer, token);
diff --git a/src/Smartstore.Web/Models/Common/AddressModel.cs b/src/Smartstore.Web/Models/Common/AddressModel.cs
index 06e59afb02..fecaa8ef7e 100644
--- a/src/Smartstore.Web/Models/Common/AddressModel.cs
+++ b/src/Smartstore.Web/Models/Common/AddressModel.cs
@@ -210,7 +210,7 @@ public AddressValidator(
RuleFor(x => x.FaxNumber).NotEmpty();
}
- When(x => x.EmailEnabled, () =>
+ When(x => x.EmailEnabled && x.ValidateEmailAddress, () =>
{
RuleFor(x => x.Email).EmailAddressStrict();
diff --git a/src/Smartstore.Web/Smartstore.Web.csproj b/src/Smartstore.Web/Smartstore.Web.csproj
index d41db5a86a..12971e001a 100644
--- a/src/Smartstore.Web/Smartstore.Web.csproj
+++ b/src/Smartstore.Web/Smartstore.Web.csproj
@@ -40,12 +40,14 @@
+
+
diff --git a/src/Smartstore.Web/Views/Checkout/Partials/PaymentMethodList.cshtml b/src/Smartstore.Web/Views/Checkout/Partials/PaymentMethodList.cshtml
index a4edd9a873..df38c72527 100644
--- a/src/Smartstore.Web/Views/Checkout/Partials/PaymentMethodList.cshtml
+++ b/src/Smartstore.Web/Views/Checkout/Partials/PaymentMethodList.cshtml
@@ -101,16 +101,15 @@
@{ await RenderPaymentMethod(); }
-
- if (payWith.Selected && widgetInvoker != null && !infoRendered)
- {
- await RenderPaymentWidget(true);
- }
}
else
{
await RenderPaymentMethod();
}
+ @if (payWith.Selected && widgetInvoker != null && !infoRendered)
+ {
+ await RenderPaymentWidget(true);
+ }
}
diff --git a/src/Smartstore.Web/wwwroot/js/smartstore.captcha.grecaptcha.js b/src/Smartstore.Web/wwwroot/js/smartstore.captcha.grecaptcha.js
index 942e1a70aa..aec3196cc5 100644
--- a/src/Smartstore.Web/wwwroot/js/smartstore.captcha.grecaptcha.js
+++ b/src/Smartstore.Web/wwwroot/js/smartstore.captcha.grecaptcha.js
@@ -4,47 +4,45 @@
// - Integrates with the generic bootstrap (CaptchaRegistry)
(function () {
- // Simple readiness helper for grecaptcha
- function waitForRecaptchaReady(check, onReady, timeoutMs) {
- const start = Date.now();
- (function tick() {
- if (check()) return onReady();
- if (Date.now() - start > (timeoutMs || 8000)) return; // give up silently
- setTimeout(tick, 50);
- })();
- }
-
- function ensureResponseInput(form) {
- var input = form.querySelector('#g-recaptcha-response');
- if (!input) {
- input = document.createElement('input');
- input.type = 'hidden';
- input.id = 'g-recaptcha-response';
- input.name = 'g-recaptcha-response';
- form.appendChild(input);
- }
-
- return input;
+ 'use strict';
+
+ var H = window.CaptchaHelpers;
+ var PROVIDER = 'Captcha.GoogleRecaptcha';
+ var RESPONSE_FIELD = 'g-recaptcha-response';
+ var ACTION_FIELD = 'captcha-action';
+ var GUARD_NAME = '__captchaResubmit';
+
+ /**
+ * Helper to check if the Google API is actually ready for the specific mode.
+ * v2 needs .render, v3 needs .execute.
+ */
+ function isRecaptchaApiReady() {
+ return window.grecaptcha && (window.grecaptcha.execute || window.grecaptcha.render);
}
function ensureActionInput(form, action) {
- var input = form.querySelector('input[name="captcha-action"]');
+ var input = form.querySelector('input[name="' + ACTION_FIELD + '"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
- input.name = 'captcha-action';
+ input.name = ACTION_FIELD;
form.appendChild(input);
}
-
input.value = action;
return input;
}
function disableSubmitTemporarily(form) {
const btn = form.querySelector('[type="submit"]');
- if (btn) { btn.disabled = true; setTimeout(function () { try { btn.disabled = false; } catch { } }, 4000); }
+ if (btn) {
+ btn.disabled = true;
+ setTimeout(function () {
+ try { btn.disabled = false; } catch { }
+ }, 4000);
+ }
}
+ // --- V3 Logic (Score based) ---
function bindV3(ctx) {
const container = ctx.container;
const cfg = ctx.config || {};
@@ -54,56 +52,53 @@
if (!siteKey) return;
const form = container.closest('form');
- if (!form || form.__captchaV3Bound) return;
- form.__captchaV3Bound = true;
-
- form.addEventListener('submit', (e) => {
- // Re-entrancy guard: when set, allow normal pipeline (incl. unobtrusive) to proceed
- if (form.__captchaResubmit === true) return;
-
- // Let native validity run first; do nothing if invalid
- if (typeof form.checkValidity === 'function' && !form.checkValidity()) return;
-
- e.preventDefault();
- disableSubmitTemporarily(form);
+ if (!form) return;
+
+ H.bindSubmit(form, GUARD_NAME, function (e, helpers) {
+ // jQuery unobtrusive validation
+ const $form = $(form);
+ const validator = $form.data('validator');
+ if (validator) {
+ $form.validate();
+ if (!$form.valid()) return true; // Allow event to proceed for validation
+ }
const exec = () => {
+ if (!window.grecaptcha || !grecaptcha.execute) {
+ console.error("reCAPTCHA v3 execute method not found.");
+ return;
+ }
+
grecaptcha.execute(siteKey, { action: action }).then((token) => {
- ensureResponseInput(form).value = token;
- // Provider-agnostic hint for servers that compare actions
+ helpers.ensureResponseInput(form, RESPONSE_FIELD).value = token;
ensureActionInput(form, action);
- // If unobtrusive AJAX is enabled, trigger a jQuery submit with a reentrancy guard
- const isUnobtrusive = !!(form.getAttribute('data-ajax') || (window.jQuery && jQuery(form).data('ajax')));
- if (isUnobtrusive && window.jQuery) {
- form.__captchaResubmit = true; // guard ON
- try { jQuery(form).trigger('submit'); } finally { form.__captchaResubmit = false; } // guard OFF
- } else {
- // Fallback: native submit
- form.submit();
- }
- }, function () {
- // Soft failure: allow form submit without token or show a message depending on policy
+ // Execute & block reCAPTCHA v3
+ helpers.resubmitForm(form, GUARD_NAME);
+ disableSubmitTemporarily(form);
+ }, function (err) {
+ console.error("reCAPTCHA v3 execution failed", err);
});
};
- // Wait for grecaptcha.execute to be available
- if (window.grecaptcha && grecaptcha.execute) {
+ if (isRecaptchaApiReady()) {
exec();
+ } else {
+ H.waitFor(isRecaptchaApiReady, exec, 8000);
}
- else {
- waitForRecaptchaReady(() => { return window.grecaptcha && grecaptcha.execute; }, exec, 8000);
- }
- });
+
+ return false; // Block submit
+ }, '__captchaV3Bound');
}
+ // --- V2 Logic (Checkbox or Invisible) ---
function bindV2(ctx) {
const container = ctx.container;
const cfg = ctx.config || {};
const siteKey = cfg.siteKey;
const size = (cfg.size || 'normal');
const theme = (cfg.theme || 'light');
- const elementId = ctx.elementId; // expected placeholder id
+ let elementId = ctx.elementId;
if (!elementId) {
const el = container.querySelector('.g-recaptcha[id]');
@@ -113,65 +108,56 @@
if (!siteKey || !elementId) return;
const form = container.closest('form');
+ const isInvisible = (size === 'invisible');
function render() {
- const badge = (size === 'invisible') ? (cfg.badge || 'bottomright') : undefined;
- const widgetId = grecaptcha.render(elementId, {
- sitekey: siteKey,
- size: size === 'invisible' ? 'invisible' : size,
- theme: theme,
- badge: badge,
- callback: (token) => {
- if (!form) return;
- ensureResponseInput(form).value = token;
-
- const isInvisible = (size === 'invisible');
- if (isInvisible) {
- var isUnobtrusive = !!(form.getAttribute('data-ajax') || (window.jQuery && jQuery(form).data('ajax')));
- if (isUnobtrusive && window.jQuery) {
- // Resubmit through jQuery to keep unobtrusive AJAX pipeline
- form.__captchaResubmit = true;
- try { jQuery(form).trigger('submit'); } finally { form.__captchaResubmit = false; }
- }
- else {
- form.submit();
+ if (!window.grecaptcha || !window.grecaptcha.render) return;
+
+ try {
+ const badge = isInvisible ? (cfg.badge || 'bottomright') : undefined;
+
+ const widgetId = grecaptcha.render(elementId, {
+ sitekey: siteKey,
+ size: isInvisible ? 'invisible' : size,
+ theme: theme,
+ badge: badge,
+ callback: (token) => {
+ if (!form) return;
+ H.ensureResponseInput(form, RESPONSE_FIELD).value = token;
+
+ if (isInvisible) {
+ H.resubmitForm(form, GUARD_NAME);
+ disableSubmitTemporarily(form);
}
+ },
+ 'expired-callback': () => {
+ if (form) H.ensureResponseInput(form, RESPONSE_FIELD).value = '';
}
- },
- 'expired-callback': () => {
- if (form) ensureResponseInput(form).value = '';
- }
- });
-
- // Invisible v2: intercept submit to trigger execution
- if (size === 'invisible' && form && !form.__captchaV2InvisibleBound) {
- form.__captchaV2InvisibleBound = true;
- form.addEventListener('submit', (e) => {
- // Allow pass-through on the guarded resubmit
- if (form.__captchaResubmit === true) return;
-
- if (typeof form.checkValidity === 'function' && !form.checkValidity()) return;
- e.preventDefault();
- disableSubmitTemporarily(form);
- grecaptcha.execute(widgetId);
});
+
+ if (isInvisible && form) {
+ H.bindSubmit(form, GUARD_NAME, function (e, helpers) {
+ grecaptcha.execute(widgetId);
+ return false; // Block submit
+ }, '__captchaV2InvisibleBound');
+ }
+ } catch (e) {
+ console.error("Failed to render reCAPTCHA v2", e);
}
}
- if (window.grecaptcha && grecaptcha.execute) {
+ if (isRecaptchaApiReady()) {
render();
- }
- else {
- waitForRecaptchaReady(() => { return window.grecaptcha && grecaptcha.execute; }, render, 8000);
+ } else {
+ H.waitFor(isRecaptchaApiReady, render, 8000);
}
}
// Register adapter
- window.CaptchaRegistry.register('Captcha.GoogleRecaptcha', {
- init: function (ctx) {
- if (!ctx || !ctx.container) return;
- if (ctx.mode === 'score') return bindV3(ctx);
- return bindV2(ctx);
- }
+ H.register(PROVIDER, function (ctx) {
+ if (!ctx || !ctx.container) return;
+ // Distinguish logic based on mode passed from server
+ if (ctx.mode === 'score') return bindV3(ctx);
+ return bindV2(ctx);
});
})();
\ No newline at end of file
diff --git a/src/Smartstore.Web/wwwroot/js/smartstore.captcha.js b/src/Smartstore.Web/wwwroot/js/smartstore.captcha.js
index 73f647e986..e55e2dc447 100644
--- a/src/Smartstore.Web/wwwroot/js/smartstore.captcha.js
+++ b/src/Smartstore.Web/wwwroot/js/smartstore.captcha.js
@@ -21,6 +21,218 @@
get: function (name) { return this.providers[name]; }
};
+ // --- Shared Helpers for Adapters ------------------------------------------
+ window.CaptchaHelpers = window.CaptchaHelpers || {
+ /**
+ * Generic polling helper for lazy-loaded global APIs
+ * @param {Function} predicate - Condition to check
+ * @param {Function} onReady - Callback when ready
+ * @param {number} timeoutMs - Timeout in milliseconds (default 8000)
+ */
+ waitFor: function (predicate, onReady, timeoutMs) {
+ const start = Date.now();
+ (function poll() {
+ try {
+ if (predicate()) { onReady(); return; }
+ } catch (_) { /* ignore */ }
+ if (Date.now() - start > (timeoutMs || 8000)) return;
+ setTimeout(poll, 50);
+ })();
+ },
+
+ /**
+ * Ensure a hidden input exists for CAPTCHA response
+ * @param {HTMLFormElement} form - The form element
+ * @param {string} fieldName - Name attribute for the input
+ * @returns {HTMLInputElement|null}
+ */
+ ensureResponseInput: function (form, fieldName) {
+ if (!form || !fieldName) return null;
+ let input = form.querySelector('input[name="' + fieldName + '"], textarea[name="' + fieldName + '"]');
+ if (!input) {
+ input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = fieldName;
+ form.appendChild(input);
+ }
+ return input;
+ },
+
+ /**
+ * Dispatch a CustomEvent on a container
+ * @param {HTMLElement} container - Event target
+ * @param {string} type - Event type
+ * @param {object} detail - Event detail object
+ */
+ dispatch: function (container, type, detail) {
+ if (!container || typeof window.CustomEvent !== 'function') return;
+ container.dispatchEvent(new CustomEvent(type, { bubbles: true, detail: detail || {} }));
+ },
+
+ /**
+ * Check if form uses jQuery Unobtrusive AJAX
+ * @param {HTMLFormElement} form
+ * @returns {boolean}
+ */
+ isUnobtrusiveAjax: function (form) {
+ return !!(form && (form.getAttribute('data-ajax') || (window.jQuery && window.jQuery(form).data('ajax'))));
+ },
+
+ /**
+ * Capture the submit button that triggered the form submit
+ * @param {HTMLFormElement} form
+ * @param {Event} event - The submit event
+ * @private
+ */
+ _captureSubmitButton: function (form, event) {
+ if (!form) return;
+
+ // Try to get the button from the event (if it's a real button click)
+ var button = null;
+
+ // 1. Check if event.submitter is available (modern browsers)
+ if (event && event.submitter) {
+ button = event.submitter;
+ }
+ // 2. Fall back to document.activeElement
+ else if (document.activeElement && document.activeElement.form === form) {
+ var active = document.activeElement;
+ if (active.tagName === 'BUTTON' || (active.tagName === 'INPUT' && (active.type === 'submit' || active.type === 'image'))) {
+ button = active;
+ }
+ }
+
+ // Store button reference on the form
+ form.__captchaSubmitButton = button;
+ },
+
+ /**
+ * Resubmit form honoring jQuery Unobtrusive AJAX and preserving submit button context
+ * Uses modern requestSubmit() when available, falls back to trigger('submit') with hidden input
+ * @param {HTMLFormElement} form
+ * @param {string} reentryGuardName - Guard property name (e.g., '__captchaResubmit')
+ */
+ resubmitForm: function (form, reentryGuardName) {
+ if (!form) return;
+ const guard = reentryGuardName || '__captchaResubmit';
+ const button = form.__captchaSubmitButton;
+
+ form[guard] = true;
+ try {
+ // Modern: Use requestSubmit (clean, native, includes button automatically)
+ if (typeof form.requestSubmit === 'function') {
+ form.requestSubmit(button || null);
+ }
+ // Fallback: jQuery Unobtrusive + temporary hidden input for legacy browsers
+ else if (this.isUnobtrusiveAjax(form) && window.jQuery) {
+ // Create temporary input for button name/value
+ var tempInput = null;
+ if (button && button.name && !button.disabled) {
+ tempInput = document.createElement('input');
+ tempInput.type = 'hidden';
+ tempInput.name = button.name;
+ tempInput.value = button.value || '';
+ form.appendChild(tempInput);
+ }
+
+ window.jQuery(form).trigger('submit');
+
+ // Cleanup after serialization (100ms delay to let unobtrusive serialize first)
+ if (tempInput) {
+ setTimeout(function () {
+ try { tempInput.remove(); } catch (_) { form.removeChild(tempInput); }
+ }, 100);
+ }
+ }
+ // Last resort: native submit (no button context, but works)
+ else {
+ form.submit();
+ }
+ } finally {
+ form[guard] = false;
+ }
+ },
+
+ /**
+ * Bind a submit handler that prevents double-submit with Unobtrusive AJAX
+ * @param {HTMLFormElement} form
+ * @param {string} guardName - Re-entrancy guard property name
+ * @param {Function} onSubmit - Callback: (event, helpers) => { return true to allow, false to block }
+ * @param {string} [boundFlagName] - Flag to prevent double-binding (optional)
+ */
+ bindSubmit: function (form, guardName, onSubmit, boundFlagName) {
+ if (!form || !guardName || !onSubmit) return;
+
+ const boundFlag = boundFlagName || ('__captcha_' + guardName + '_bound');
+ if (form[boundFlag]) return;
+ form[boundFlag] = true;
+
+ const self = this;
+ form.addEventListener('submit', function (e) {
+ // Re-entrancy guard: allow pass-through
+ if (form[guardName] === true) return;
+
+ // Capture submit button BEFORE any validation/blocking
+ self._captureSubmitButton(form, e);
+
+ // Native validation first
+ if (typeof form.checkValidity === 'function' && !form.checkValidity()) return;
+
+ // Call adapter logic; if it returns false, stop propagation
+ const shouldBlock = onSubmit(e, self);
+ if (shouldBlock === false) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ }
+ }, true); // Capture phase
+ },
+
+ /**
+ * Create metadata object for events
+ * @param {string} provider - Provider name
+ * @param {object} config - Config object
+ * @param {HTMLFormElement} form - Form element
+ * @returns {object}
+ */
+ createMeta: function (provider, config, form) {
+ return {
+ provider: provider || 'Unknown',
+ formId: (form && form.id) || null,
+ config: config || {}
+ };
+ },
+
+ /**
+ * Register an adapter with the CaptchaRegistry (waits if not ready yet)
+ * @param {string} providerName - Provider name (e.g., 'Captcha.FriendlyCaptcha')
+ * @param {Function} initFunction - Adapter init function
+ * @param {string} [errorEventName] - Optional error event name for error dispatching
+ */
+ register: function (providerName, initFunction, errorEventName) {
+ var self = this;
+ (function tryRegister() {
+ if (!window.CaptchaRegistry || typeof window.CaptchaRegistry.register !== 'function') {
+ self.waitFor(function () {
+ return window.CaptchaRegistry && typeof window.CaptchaRegistry.register === 'function';
+ }, tryRegister, 8000);
+ return;
+ }
+
+ window.CaptchaRegistry.register(providerName, {
+ init: function (ctx) {
+ try {
+ initFunction(ctx);
+ } catch (error) {
+ if (ctx && ctx.container && errorEventName) {
+ self.dispatch(ctx.container, errorEventName, { error: error });
+ }
+ }
+ }
+ });
+ })();
+ }
+ };
+
// --- Utilities -------------------------------------------------------------
function readConfig(container) {
const node = container.querySelector('script.captcha-config[type="application/json"]');
diff --git a/src/Smartstore.Web/wwwroot/js/smartstore.common.js b/src/Smartstore.Web/wwwroot/js/smartstore.common.js
index 15662cde23..52e0462743 100644
--- a/src/Smartstore.Web/wwwroot/js/smartstore.common.js
+++ b/src/Smartstore.Web/wwwroot/js/smartstore.common.js
@@ -447,6 +447,18 @@
return $('meta[name="__rvt"]').attr("content") || $('input[name="__RequestVerificationToken"]').val();
};
+ window.disableSubmitTemporarily = function (form, duration = 4000) {
+ if (!form) return;
+
+ const submitButtons = form.querySelectorAll('button[type="submit"], input[type="submit"]');
+ submitButtons.forEach(btn => {
+ btn.disabled = true;
+ setTimeout(() => {
+ try { btn.disabled = false; } catch { }
+ }, duration);
+ });
+ }
+
/**
* Render a JSON object as an HTML tree using
rows.
* - One
per entry/row.
diff --git a/src/Smartstore.Web/wwwroot/js/smartstore.globalinit.js b/src/Smartstore.Web/wwwroot/js/smartstore.globalinit.js
index 50104eada3..beda4f29a3 100644
--- a/src/Smartstore.Web/wwwroot/js/smartstore.globalinit.js
+++ b/src/Smartstore.Web/wwwroot/js/smartstore.globalinit.js
@@ -80,13 +80,23 @@ jQuery(function () {
});
// Prevent (button) multiclick
- $(document).on('click', '.btn-prevent-multiclick', function () {
+ $(document).on('click', '.btn-prevent-multiclick', function (e) {
let el = $(this);
let containingForm = el.closest("form");
if (containingForm.length) {
el.prop('disabled', true);
- containingForm.trigger('submit');
+
+ const submitEvent = new Event('submit', {
+ bubbles: true,
+ cancelable: true
+ });
+ const nativePrevented = !containingForm[0].dispatchEvent(submitEvent);
+
+ // If native handlers (from captcha js) have NOT called preventDefault(), trigger jQuery.
+ if (!nativePrevented) {
+ containingForm.trigger('submit');
+ }
if (!containingForm.valid()) {
el.prop('disabled', false);