/**
* AgentForms Embed SDK
* @version 2.0.0
* @license MIT
*
* Usage:
* <script src="https://agentforms.io/embed.js"></script>
* <div id="my-form"></div>
* <script>
* AF.render('my-form', {
* token: 'abc123',
* theme: 'light',
* successMessage: "Thanks! We'll be in touch.",
* onSubmit: function(data) { /* analytics hook */ }
* });
* </script>
*/
(function (window, document) {
"use strict";
var BASE = location.protocol + "//" + location.host;
var DEFAULTS = {
token: "",
theme: "light",
successMessage: "Thank you! Your submission was received.",
errorMessage: "Something went wrong. Please try again.",
submitLabel: "Submit",
nextLabel: "Next",
prevLabel: "Back",
stepLabel: "Step {current} of {total}",
colors: {},
onSubmit: null,
onError: null,
};
var THEMES = {
light: {
bg: "#ffffff",
text: "#333333",
label: "#555555",
inputBg: "#ffffff",
inputBorder: "#cccccc",
inputText: "#333333",
primary: "#1a1a2e",
primaryText: "#ffffff",
error: "#dc3545",
errorBg: "#f8d7da",
success: "#155724",
successBg: "#d4edda",
placeholder: "#999999",
},
dark: {
bg: "#1a1a2e",
text: "#e0e0e0",
label: "#cccccc",
inputBg: "#2d2d44",
inputBorder: "#444466",
inputText: "#e0e0e0",
primary: "#4a9eff",
primaryText: "#ffffff",
error: "#ff6b6b",
errorBg: "#3d2020",
success: "#51cf66",
successBg: "#1d3d20",
placeholder: "#777777",
},
transparent: {
bg: "transparent",
text: "#333333",
label: "#555555",
inputBg: "rgba(255,255,255,0.9)",
inputBorder: "#cccccc",
inputText: "#333333",
primary: "#1a1a2e",
primaryText: "#ffffff",
error: "#dc3545",
errorBg: "rgba(248,215,218,0.8)",
success: "#155724",
successBg: "rgba(212,237,218,0.8)",
placeholder: "#999999",
},
};
var instances = {};
function deepMerge(base, override) {
var result = {};
for (var key in base) {
if (base.hasOwnProperty(key)) {
result[key] = base[key];
}
}
for (var key in override) {
if (override.hasOwnProperty(key)) {
result[key] = override[key];
}
}
return result;
}
function escapeHtml(str) {
var div = document.createElement("div");
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function generateId() {
return "af_" + Math.random().toString(36).substr(2, 9);
}
// Track impression via sendBeacon
function trackImpression(token) {
if (!token || typeof navigator.sendBeacon !== "function") return;
var data = JSON.stringify({
token: token,
event: "impression",
timestamp: Date.now(),
referrer: document.referrer || "",
});
navigator.sendBeacon(BASE + "/api/beacon", data);
}
// Fetch field config
function fetchConfig(token, callback) {
var xhr = new XMLHttpRequest();
xhr.open("GET", BASE + "/api/v2/forms/" + token + "/config", true);
xhr.timeout = 10000;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
callback(null, JSON.parse(xhr.responseText));
} catch (e) {
callback(new Error("Invalid response from server"));
}
} else {
callback(new Error("Failed to load form (" + xhr.status + ")"));
}
}
};
xhr.onerror = function () {
callback(new Error("Network error"));
};
xhr.ontimeout = function () {
callback(new Error("Request timed out"));
};
xhr.send();
}
// ─── Session Tracking (Phase 10) ───────────────────────────────────────────
var _sessionState = {};
function getSessionId() {
try {
var existing = localStorage.getItem("af_session_id");
if (existing) return existing;
} catch (e) {}
var id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
try { localStorage.setItem("af_session_id", id); } catch (e) {}
return id;
}
function getVisitorId(token) {
var key = "af_visitor_" + token;
try {
var existing = localStorage.getItem(key);
if (existing) return existing;
} catch (e) {}
var id = 'visitor_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 8);
try { localStorage.setItem(key, id); } catch (e) {}
return id;
}
function initSessionTracking(token, variantKey) {
_sessionState = {
token: token,
sessionId: getSessionId(),
visitorId: getVisitorId(token),
fieldsViewed: [],
lastFieldReached: "",
stepReached: 0,
startTime: Date.now(),
variantKey: variantKey || null,
tracking: true,
debounceTimer: null,
};
// Fire session_start
sendSessionEvent("session_start");
}
function sendSessionEvent(event, data) {
if (!_sessionState || !_sessionState.tracking) return;
var payload = {
token: _sessionState.token,
session_id: _sessionState.sessionId,
visitor_id: _sessionState.visitorId,
event: event,
fields_viewed: _sessionState.fieldsViewed.slice(),
last_field_reached: _sessionState.lastFieldReached,
step_reached: _sessionState.stepReached,
referrer: document.referrer || "",
timestamp: Date.now(),
};
// Include variant info in metadata
if (_sessionState.variantKey) {
payload.variant_key = _sessionState.variantKey;
}
if (data) {
for (var k in data) {
if (data.hasOwnProperty(k)) payload[k] = data[k];
}
}
try {
navigator.sendBeacon(BASE + "/api/sessions", JSON.stringify(payload));
} catch (e) {}
}
function trackFieldInteraction(fieldKey) {
if (!_sessionState || !_sessionState.tracking || !fieldKey) return;
// Track if not already seen
if (_sessionState.fieldsViewed.indexOf(fieldKey) === -1) {
_sessionState.fieldsViewed.push(fieldKey);
}
// Update last field reached
_sessionState.lastFieldReached = fieldKey;
// Debounced update (fire after 2s of no activity)
if (_sessionState.debounceTimer) clearTimeout(_sessionState.debounceTimer);
_sessionState.debounceTimer = setTimeout(function () {
sendSessionEvent("session_update");
_sessionState.debounceTimer = null;
}, 2000);
}
function trackStepChange(stepIndex) {
if (!_sessionState) return;
_sessionState.stepReached = stepIndex + 1;
}
function completeSession() {
if (!_sessionState) return;
sendSessionEvent("session_complete");
_sessionState.tracking = false;
if (_sessionState.debounceTimer) clearTimeout(_sessionState.debounceTimer);
// Clear session ID so next visit starts fresh
try { localStorage.removeItem("af_session_id"); } catch (e) {}
}
// ─── A/B Testing (Phase 10.9) ───────────────────────────────────────────
function fetchVariant(token, visitorId, callback) {
var xhr = new XMLHttpRequest();
xhr.open("GET", BASE + "/api/v1/variants?token=" + encodeURIComponent(token) + "&visitor_id=" + encodeURIComponent(visitorId), true);
xhr.timeout = 5000;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
callback(null, resp);
} catch (e) {
callback(new Error("Invalid variant response"));
}
} else {
callback(null, { has_active_test: false, assigned_variant: null });
}
}
};
xhr.onerror = function () {
callback(null, { has_active_test: false, assigned_variant: null });
};
xhr.ontimeout = function () {
callback(null, { has_active_test: false, assigned_variant: null });
};
xhr.send();
}
// ─── Field Rendering ───────────────────────────────────────────────────────
function renderField(field, formId) {
var wrapper = document.createElement("div");
wrapper.className = "af-field";
wrapper.setAttribute("data-af-field", field.key || field.name);
var fieldId = formId + "_" + (field.key || field.name);
// Label
var label = document.createElement("label");
label.className = "af-label";
label.setAttribute("for", fieldId);
label.textContent = field.label || field.key || field.name;
if (field.required) {
var req = document.createElement("span");
req.className = "af-required";
req.textContent = " *";
label.appendChild(req);
}
wrapper.appendChild(label);
// Input element based on type
var input = null;
var type = field.type || "text";
switch (type) {
case "textarea":
input = document.createElement("textarea");
input.rows = 4;
break;
case "select":
input = document.createElement("select");
if (field.options && field.options.length > 0) {
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = field.placeholder || "Select...";
input.appendChild(defaultOpt);
for (var i = 0; i < field.options.length; i++) {
var opt = document.createElement("option");
opt.value = field.options[i];
opt.textContent = field.options[i];
input.appendChild(opt);
}
}
break;
case "checkbox":
input = document.createElement("input");
input.type = "checkbox";
input.value = "1";
break;
case "radio":
var radioGroup = document.createElement("div");
radioGroup.className = "af-radio-group";
if (field.options && field.options.length > 0) {
for (var j = 0; j < field.options.length; j++) {
var radioWrapper = document.createElement("label");
radioWrapper.className = "af-radio-label";
var radio = document.createElement("input");
radio.type = "radio";
radio.name = field.key || field.name;
radio.value = field.options[j];
radio.id = fieldId + "_" + j;
var radioSpan = document.createElement("span");
radioSpan.textContent = field.options[j];
radioWrapper.appendChild(radio);
radioWrapper.appendChild(radioSpan);
radioGroup.appendChild(radioWrapper);
}
}
wrapper.appendChild(radioGroup);
input = document.createElement("span");
input.style.display = "none";
input.className = "af-input";
input.id = fieldId;
input.setAttribute("name", field.key || field.name);
wrapper.appendChild(input);
return wrapper;
case "number":
input = document.createElement("input");
input.type = "number";
input.step = "any";
break;
case "email":
input = document.createElement("input");
input.type = "email";
break;
case "phone":
input = document.createElement("input");
input.type = "tel";
break;
case "file":
input = document.createElement("input");
input.type = "file";
break;
default:
input = document.createElement("input");
input.type = "text";
}
input.className = "af-input";
input.id = fieldId;
input.setAttribute("name", field.key || field.name);
if (field.placeholder) {
input.placeholder = field.placeholder;
}
if (field.required) {
input.setAttribute("required", "required");
}
if (field.min) input.setAttribute("min", field.min);
if (field.max) input.setAttribute("max", field.max);
if (field.minLength) input.setAttribute("minlength", field.minLength);
if (field.maxLength) input.setAttribute("maxlength", field.maxLength);
// Session tracking: track field interaction
var fieldKey = field.key || field.name;
input.addEventListener("focus", function () { trackFieldInteraction(fieldKey); }, { once: true });
input.addEventListener("change", function () { trackFieldInteraction(fieldKey); });
wrapper.appendChild(input);
return wrapper;
}
// ─── Conditional field rendering ──────────────────────────────────────────
function _evaluateSingleCondition(condition, formData) {
if (!condition || !condition.field || !condition.operator) return true;
var targetValue = formData[condition.field] || "";
targetValue = String(targetValue).trim();
// Boolean operators
if (condition.operator === "filled") return targetValue !== "";
if (condition.operator === "empty") return targetValue === "";
var compareValue = String(condition.value || "").trim();
// Numeric comparisons
if (["gt", "lt", "gte", "lte"].indexOf(condition.operator) !== -1) {
var t = parseFloat(targetValue);
var c = parseFloat(compareValue);
if (isNaN(t) || isNaN(c)) return false;
if (condition.operator === "gt") return t > c;
if (condition.operator === "lt") return t < c;
if (condition.operator === "gte") return t >= c;
if (condition.operator === "lte") return t <= c;
}
// String comparisons
if (condition.operator === "eq") return targetValue === compareValue;
if (condition.operator === "neq") return targetValue !== compareValue;
if (condition.operator === "contains") return targetValue.indexOf(compareValue) !== -1;
if (condition.operator === "not_contains") return targetValue.indexOf(compareValue) === -1;
// Set membership
if (condition.operator === "in") {
var values = compareValue.split(",").map(function(v){ return v.trim(); });
return values.indexOf(targetValue) !== -1;
}
if (condition.operator === "not_in") {
var values2 = compareValue.split(",").map(function(v){ return v.trim(); });
return values2.indexOf(targetValue) === -1;
}
return true; // Unknown operator = visible (fail open)
}
// ─── Compute field value from config (Phase 3) ────────────────────────────
function _computeFieldValue(fieldDef, formData) {
// Method 1: calculation.formula (legacy)
var calc = fieldDef && fieldDef.calculation;
if (calc && calc.formula) {
var formula = calc.formula;
var keys = calc.keys || [];
var result = formula;
for (var k = 0; k < keys.length; k++) {
var key = keys[k];
var val = formData[key] || "";
result = result.replace(new RegExp('\\{' + key + '\\}', 'g'), val);
}
// Try to parse as number
var num = parseFloat(result);
return isNaN(num) ? result : num;
}
// Method 2: computed_method: 'map'
if (fieldDef && fieldDef.computed_method === 'map') {
var source = fieldDef.computed_from || "";
var mapping = fieldDef.computed_map || {};
var sourceValue = String(formData[source] || "").trim();
return mapping[sourceValue] !== undefined ? mapping[sourceValue] : (fieldDef.default || "");
}
// Method 3: computed_method: 'expression'
if (fieldDef && fieldDef.computed_method === 'expression') {
try {
var expr = fieldDef.computed_expression || "";
// Replace {key} with values (numeric coercion)
var safeExpr = expr;
var fieldKeys = fieldDef.computed_from || [];
if (!Array.isArray(fieldKeys)) fieldKeys = [fieldKeys];
for (var fk = 0; fk < fieldKeys.length; fk++) {
var kv = fieldKeys[fk];
var rawVal = formData[kv];
var numVal = parseFloat(rawVal);
safeExpr = safeExpr.replace(new RegExp('\\{' + kv + '\\}', 'g'), isNaN(numVal) ? 0 : numVal);
}
// Safety: only allow math operators, parentheses, whitespace, dots, minus
if (/^[\d\s\+\-\*\/\(\)\.,]+$/.test(safeExpr)) {
var evalResult = Function('"use strict"; return (' + safeExpr + ')')();
return evalResult;
}
} catch (e) { /* silent fail */ }
return fieldDef.default || "";
}
return formData[fieldDef.key] || fieldDef.default || "";
}
function _extractComputedFields(fieldDef) {
var refs = [];
var calc = fieldDef.calculation;
if (calc && calc.keys) {
for (var i = 0; i < calc.keys.length; i++) {
refs.push(calc.keys[i]);
}
return refs;
}
if (fieldDef.computed_method === 'map' && fieldDef.computed_from) {
refs.push(fieldDef.computed_from);
}
if (fieldDef.computed_method === 'expression') {
var expr = fieldDef.computed_expression || "";
var match;
var re = /\{(\w+)\}/g;
while ((match = re.exec(expr)) !== null) {
refs.push(match[1]);
}
}
return refs;
}
function _applyComputedFields(form, formData, fieldConfig) {
var fields = fieldConfig.fields || [];
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
var isComputed = field.calculation || field.computed_method;
if (!isComputed) continue;
var key = field.key || field.name;
var wrapper = form.querySelector('[data-af-field="' + key + '"]');
if (!wrapper) continue;
var inputs = wrapper.querySelectorAll('input[name="' + key + '"], textarea[name="' + key + '"], select[name="' + key + '"]');
if (inputs.length === 0) continue;
var computedValue = _computeFieldValue(field, formData);
inputs[0].value = computedValue !== undefined ? computedValue : "";
inputs[0].setAttribute('readonly', 'readonly');
inputs[0].style.backgroundColor = '#f0f0f0';
}
}
function evaluateCondition(condition, formData) {
if (!condition) return true;
// AND group — all sub-conditions must be true
if (condition.all && condition.all.length > 0) {
for (var i = 0; i < condition.all.length; i++) {
if (!evaluateCondition(condition.all[i], formData)) return false;
}
return true;
}
// OR group — at least one sub-condition must be true
if (condition.any && condition.any.length > 0) {
for (var j = 0; j < condition.any.length; j++) {
if (evaluateCondition(condition.any[j], formData)) return true;
}
return false;
}
// Legacy single condition
return _evaluateSingleCondition(condition, formData);
}
// ─── Client-side validation (real-time feedback) ────────────────────────
function validateField(field, form, theme) {
var errors = [];
var key = field.key || field.name;
var wrapper = form.querySelector('[data-af-field="' + key + '"]');
if (!wrapper) return errors;
// Check if field is hidden by condition
if (wrapper.style.display === "none") return errors;
// Get input element(s)
var inputs = wrapper.querySelectorAll('input[name="' + key + '"], textarea[name="' + key + '"], select[name="' + key + '"]');
if (inputs.length === 0) return errors;
var value = "";
if (field.type === "radio") {
var checked = form.querySelector('input[name="' + key + '"]:checked');
value = checked ? checked.value : "";
} else if (field.type === "checkbox") {
value = inputs[0].checked ? "1" : "";
} else {
value = inputs[0].value || "";
}
// Required check
if (field.required && !value) {
errors.push(field.errorMessage || (field.label || key) + " is required.");
return errors;
}
if (!value) return errors; // Skip other checks on empty non-required
var validation = field.validation || {};
var vtype = validation.type || field.type || "";
// Pattern check (regex)
var pattern = validation.pattern || field.pattern || "";
if (pattern) {
try {
if (!new RegExp(pattern).test(value)) {
errors.push(validation.patternMessage || "Please enter a valid value.");
}
} catch (e) { /* skip invalid regex */ }
}
// Built-in email validation
if (vtype === "email" || field.type === "email") {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
if (errors.length === 0) {
errors.push(validation.message || "Please enter a valid email address.");
}
}
}
// Min/max for numbers
if (field.type === "number" || vtype === "number") {
var num = parseFloat(value);
if (!isNaN(num)) {
if (validation.min !== undefined && num < parseFloat(validation.min)) {
errors.push(validation.minMessage || "Value must be at least " + validation.min + ".");
}
if (validation.max !== undefined && num > parseFloat(validation.max)) {
errors.push(validation.maxMessage || "Value must be at most " + validation.max + ".");
}
}
}
// Min/max length for text
if (validation.minLength !== undefined && value.length < parseInt(validation.minLength)) {
errors.push(validation.minLengthMessage || "Please enter at least " + validation.minLength + " characters.");
}
if (validation.maxLength !== undefined && value.length > parseInt(validation.maxLength)) {
errors.push(validation.maxLengthMessage || "Please enter no more than " + validation.maxLength + " characters.");
}
return errors;
}
function showFieldError(wrapper, message, theme) {
// Clear previous
var prev = wrapper.querySelector(".af-field-error");
if (prev) prev.remove();
var err = document.createElement("div");
err.className = "af-field-error";
err.style.cssText = "color:" + theme.error + ";font-size:12px;margin-top:4px;";
err.textContent = message;
wrapper.appendChild(err);
// Highlight input
var inp = wrapper.querySelector(".af-input");
if (inp) {
inp.setAttribute("data-af-error", "true");
inp.style.borderColor = theme.error;
}
}
function clearFieldError(wrapper, inp) {
var prev = wrapper.querySelector(".af-field-error");
if (prev) prev.remove();
if (inp) {
inp.removeAttribute("data-af-error");
inp.style.borderColor = "";
}
}
function wireRealTimeValidation(form, fieldConfig, theme) {
var fields = fieldConfig.fields || [];
var steps = fieldConfig.steps;
if (steps) {
var stepKeys = Object.keys(steps);
for (var sk = 0; sk < stepKeys.length; sk++) {
fields = fields.concat(steps[stepKeys[sk]]);
}
}
for (var i = 0; i < fields.length; i++) {
(function(fieldDef) {
var wrapper = form.querySelector('[data-af-field="' + (fieldDef.key || fieldDef.name) + '"]');
if (!wrapper) return;
var inputs = wrapper.querySelectorAll("input, textarea, select");
var events = fieldDef.type === "radio" ? ["change"] : ["blur", "input"];
for (var e = 0; e < events.length; e++) {
(function(evt, fDef, w, inpEls) {
wrapper.addEventListener(evt, function() {
var errs = validateField(fDef, form, theme);
if (errs.length > 0) {
showFieldError(w, errs[0], theme);
} else {
for (var ii = 0; ii < inpEls.length; ii++) {
clearFieldError(w, inpEls[ii]);
}
}
});
})(events[e], fieldDef, wrapper, inputs);
}
})(fields[i]);
}
}
function getFormData(form) {
var data = {};
var inputs = form.querySelectorAll("input, select, textarea");
for (var i = 0; i < inputs.length; i++) {
var el = inputs[i];
if (el.name && el.type !== "submit" && el.type !== "button" && el.id.indexOf("af_") !== 0) {
if (el.type === "checkbox" || el.type === "radio") {
if (el.checked) data[el.name] = el.value;
} else {
data[el.name] = el.value;
}
}
}
return data;
}
// Extract all field names referenced by a condition (recursive for groups)
function _extractConditionFields(condition) {
if (!condition) return [];
var refs = [];
if (condition.field) {
refs.push(condition.field);
}
if (condition.all) {
for (var i = 0; i < condition.all.length; i++) {
refs = refs.concat(_extractConditionFields(condition.all[i]));
}
}
if (condition.any) {
for (var j = 0; j < condition.any.length; j++) {
refs = refs.concat(_extractConditionFields(condition.any[j]));
}
}
return refs;
}
function _getConditionalState(fieldDef, formData, visibleFields) {
var key = fieldDef.key || fieldDef.name;
var state = {
visible: true,
required: !!fieldDef.required,
default: fieldDef.default || "",
validationOverrides: {},
};
// Visibility
if (fieldDef.condition) {
state.visible = evaluateCondition(fieldDef.condition, formData);
}
// Cascade: if any referenced field is hidden, this field is hidden
if (state.visible) {
var refs = _extractConditionFields(fieldDef.condition);
for (var r = 0; r < refs.length; r++) {
if (!visibleFields[refs[r]]) {
state.visible = false;
break;
}
}
}
// Conditional required
if (fieldDef.conditional_required) {
if (_evaluateSingleCondition(fieldDef.conditional_required, formData)) {
state.required = true;
}
}
// Conditional default
if (fieldDef.conditional_default && fieldDef.conditional_default.condition) {
if (_evaluateSingleCondition(fieldDef.conditional_default.condition, formData)) {
state.default = fieldDef.conditional_default.value || "";
}
}
// Conditional validation
if (fieldDef.conditional_validation) {
for (var v = 0; v < fieldDef.conditional_validation.length; v++) {
var cv = fieldDef.conditional_validation[v];
if (cv.condition && _evaluateSingleCondition(cv.condition, formData)) {
state.validationOverrides = {};
var overrideKeys = ["pattern", "errorMessage", "minLength", "maxLength", "min", "max"];
for (var k = 0; k < overrideKeys.length; k++) {
if (cv[overrideKeys[k]] !== undefined) {
state.validationOverrides[overrideKeys[k]] = cv[overrideKeys[k]];
}
}
break; // First matching rule wins
}
}
}
return state;
}
function _evaluateAllClientConditions(form, fields) {
var formData = getFormData(form);
var states = {};
var visibleFields = {};
for (var iter = 0; iter < 3; iter++) {
var newStates = {};
for (var i = 0; i < fields.length; i++) {
var key = fields[i].key || fields[i].name;
newStates[key] = _getConditionalState(fields[i], formData, visibleFields);
}
// Check if stable
var stable = true;
for (var k in newStates) {
if (newStates[k].visible !== states[k].visible) {
stable = false;
break;
}
}
if (stable && Object.keys(states).length > 0) break;
states = newStates;
for (var fk in states) {
visibleFields[fk] = states[fk].visible;
}
}
return states;
}
function updateFieldVisibility(form, fieldConfig) {
var fields = fieldConfig.fields || [];
var steps = fieldConfig.steps;
if (steps) {
var stepKeys = Object.keys(steps);
for (var sk = 0; sk < stepKeys.length; sk++) {
fields = fields.concat(steps[stepKeys[sk]]);
}
}
var states = _evaluateAllClientConditions(form, fields);
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
var fieldKey = field.key || field.name;
var state = states[fieldKey];
if (!state) continue;
var wrapper = form.querySelector('[data-af-field="' + fieldKey + '"]');
if (!wrapper) continue;
// Visibility
if (state.visible) {
wrapper.style.display = "";
wrapper.style.visibility = "";
} else {
wrapper.style.display = "none";
wrapper.style.visibility = "hidden";
}
// Apply conditional required
var label = wrapper.querySelector(".af-label .af-required");
var input = wrapper.querySelector(".af-input");
if (state.required) {
if (input) input.setAttribute("required", "required");
if (!label) {
// Add required indicator if not present
var parentLabel = wrapper.querySelector(".af-label");
if (parentLabel) {
var req = document.createElement("span");
req.className = "af-required";
req.textContent = " *";
parentLabel.appendChild(req);
}
}
} else {
if (input && !field.required) {
input.removeAttribute("required");
}
// Remove dynamic required indicator
if (label && !field.required) {
label.remove();
}
}
// Apply conditional default (only if field is empty)
if (state.default && input && !input.value) {
input.value = state.default;
}
}
}
function wireConditionListeners(form, fieldConfig) {
var fields = fieldConfig.fields || [];
var steps = fieldConfig.steps;
if (steps) {
var stepKeys = Object.keys(steps);
for (var sk = 0; sk < stepKeys.length; sk++) {
fields = fields.concat(steps[stepKeys[sk]]);
}
}
// Find all target fields that conditions depend on (recursive for groups)
var targetFields = {};
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
// Extract from condition (visibility)
if (field.condition) {
var refs = _extractConditionFields(field.condition);
for (var r = 0; r < refs.length; r++) {
targetFields[refs[r]] = true;
}
}
// Extract from conditional_required
if (field.conditional_required && field.conditional_required.field) {
targetFields[field.conditional_required.field] = true;
}
// Extract from conditional_default
if (field.conditional_default && field.conditional_default.condition) {
var defRefs = _extractConditionFields(field.conditional_default.condition);
for (var d = 0; d < defRefs.length; d++) {
targetFields[defRefs[d]] = true;
}
}
// Extract from conditional_validation
if (field.conditional_validation) {
for (var v = 0; v < field.conditional_validation.length; v++) {
var cvRefs = _extractConditionFields(field.conditional_validation[v].condition);
for (var c = 0; c < cvRefs.length; c++) {
targetFields[cvRefs[c]] = true;
}
}
}
// Extract from computed fields (map/expression/formula)
var computedRefs = _extractComputedFields(field);
for (var cr = 0; cr < computedRefs.length; cr++) {
targetFields[computedRefs[cr]] = true;
}
}
// Wire up listeners on target fields
var inputs = form.querySelectorAll("input, select, textarea");
for (var j = 0; j < inputs.length; j++) {
var el = inputs[j];
if (targetFields[el.name]) {
(function(elem) {
var events = ["change", "input"];
for (var e = 0; e < events.length; e++) {
elem.addEventListener(events[e], function() {
var currentData = getFormData(form);
updateFieldVisibility(form, fieldConfig);
_applyComputedFields(form, currentData, fieldConfig);
});
}
})(el);
}
}
// Initial visibility + computed fields pass
updateFieldVisibility(form, fieldConfig);
_applyComputedFields(form, getFormData(form), fieldConfig);
}
// Honeypot field
function createHoneypot() {
var wrapper = document.createElement("div");
wrapper.className = "af-honeypot";
wrapper.style.display = "none";
wrapper.style.visibility = "hidden";
wrapper.style.height = "0";
wrapper.style.overflow = "hidden";
var label = document.createElement("label");
label.setAttribute("for", "af_website");
label.textContent = "Website (leave empty)";
var input = document.createElement("input");
input.type = "text";
input.name = "af_website";
input.id = "af_website";
input.tabIndex = -1;
input.autocomplete = "off";
wrapper.appendChild(label);
wrapper.appendChild(input);
return wrapper;
}
// Submit button
function createButton(type, label) {
var btn = document.createElement("button");
btn.type = type;
btn.className = "af-submit";
var btnText = document.createElement("span");
btnText.className = "af-btn-text";
btnText.textContent = label;
btn.appendChild(btnText);
var spinner = document.createElement("span");
spinner.className = "af-spinner";
btn.appendChild(spinner);
return btn;
}
// Message container
function createMessage() {
var msg = document.createElement("div");
msg.className = "af-message";
return msg;
}
// Build CSS with multi-step support
function buildCss(theme) {
var c = theme;
return (
".af-form{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" +
"background:" + c.bg + ";color:" + c.text + ";padding:24px;border-radius:8px;max-width:600px;margin:0 auto;" +
"box-sizing:border-box}.af-form *{box-sizing:border-box}.af-field{margin-bottom:16px}.af-label{" +
"display:block;margin-bottom:6px;font-weight:600;color:" + c.label + ";" +
"font-size:14px;line-height:1.4}.af-required{color:" + c.error + "}.af-input{" +
"width:100%;padding:10px 12px;border:1px solid " + c.inputBorder + ";border-radius:6px;" +
"background:" + c.inputBg + ";color:" + c.inputText + ";font-size:15px;line-height:1.5;" +
"transition:border-color 0.2s,box-shadow 0.2s}.af-input:focus{" +
"outline:none;border-color:" + c.primary + ";box-shadow:0 0 0 3px " + c.primary + "22}.af-input::placeholder{color:" + c.placeholder +
"}.af-input:disabled{opacity:0.6;cursor:not-allowed}.af-radio-group{" +
"display:flex;flex-direction:column;gap:8px}.af-radio-label{" +
"display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:400}.af-radio-label input{" +
"width:auto;flex-shrink:0}.af-submit{display:inline-flex;align-items:center;justify-content:center;" +
"gap:8px;padding:12px 24px;background:" + c.primary + ";color:" + c.primaryText +
";border:none;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;" +
"transition:opacity 0.2s,transform 0.1s;width:100%;margin-top:8px}" +
".af-submit:hover{opacity:0.9}.af-submit:active{transform:scale(0.98)}" +
".af-submit:disabled{opacity:0.6;cursor:not-allowed;transform:none}.af-submit.af-prev{" +
"background:transparent;color:" + c.text + ";border:1px solid " + c.inputBorder + "}" +
".af-spinner{" +
"display:none;width:16px;height:16px;border:2px solid " + c.primaryText + "33;" +
"border-top-color:" + c.primaryText + ";border-radius:50%;animation:af-spin 0.6s linear infinite}." +
"af-submit.loading .af-spinner{display:inline-block}.af-submit.loading .af-btn-text{" +
"visibility:hidden}.af-message{padding:12px 16px;border-radius:6px;margin-top:16px;" +
"font-size:14px;display:none;text-align:center}@keyframes af-spin{" +
"to{transform:rotate(360deg)}}.af-honeypot{position:absolute;left:-9999px!important}" +
".af-progress{margin-bottom:24px}.af-progress-bar{display:flex;gap:6px;margin-bottom:8px}" +
".af-progress-segment{flex:1;height:4px;border-radius:2px;background:" + c.inputBorder + ";" +
"transition:background 0.3s ease}.af-progress-segment.active{background:" + c.primary +
"}.af-progress-segment.done{background:" + c.success +
"}.af-progress-label{font-size:12px;color:" + c.label + ";text-align:center;font-weight:600}" +
".af-step-nav{display:flex;gap:8px;margin-top:16px}.af-step-nav .af-submit{width:auto;flex:1}" +
".af-step{display:none}.af-step.active{display:block}" +
"@media(max-width:480px){.af-form{padding:16px}.af-input{padding:12px;font-size:16px}" +
".af-submit{padding:14px;font-size:16px}}"
);
}
// Client-side validation for a step's fields
function validateStepFields(stepFields, form) {
var errors = [];
for (var i = 0; i < stepFields.length; i++) {
var fld = stepFields[i];
var key = fld.key || fld.name;
var wrapper = form.querySelector('[data-af-field="' + key + '"]');
if (!wrapper) continue;
// Required check
if (fld.required) {
if (fld.type === "radio") {
var checked = form.querySelector('input[name="' + key + '"]:checked');
if (!checked) {
errors.push({ field: key, message: (fld.errorMessage || (fld.label || key) + " is required."), rule: "required" });
}
} else {
var input = wrapper.querySelector('input[name="' + key + '"], textarea[name="' + key + '"], select[name="' + key + '"]');
if (input) {
var val = input.type === "checkbox" ? (input.checked ? "1" : "") : input.value;
if (!val || val === "") {
errors.push({ field: key, message: (fld.errorMessage || (fld.label || key) + " is required."), rule: "required" });
}
}
}
}
}
return errors;
}
// Show field-level error highlights
function showFieldErrors(errors, form, theme) {
// Clear previous errors
var prevErrors = form.querySelectorAll(".af-field-error");
for (var k = 0; k < prevErrors.length; k++) {
prevErrors[k].remove();
}
var prevHighlights = form.querySelectorAll("[data-af-error]");
for (var m = 0; m < prevHighlights.length; m++) {
prevHighlights[m].removeAttribute("data-af-error");
var inp = prevHighlights[m].querySelector(".af-input");
if (inp) inp.style.borderColor = "";
}
// Show new errors
for (var i = 0; i < errors.length; i++) {
var err = errors[i];
var wrapper = form.querySelector('[data-af-field="' + err.field + '"]');
if (!wrapper) continue;
// Highlight input
var inp = wrapper.querySelector(".af-input");
if (inp) {
inp.setAttribute("data-af-error", "true");
inp.style.borderColor = theme.error;
}
// Error message
var errEl = document.createElement("div");
errEl.className = "af-field-error";
errEl.style.color = theme.error;
errEl.style.fontSize = "12px";
errEl.style.marginTop = "4px";
errEl.textContent = err.message;
wrapper.appendChild(errEl);
}
// Focus first error
var firstError = form.querySelector('[data-af-field="' + errors[0].field + '"] .af-input');
if (firstError) firstError.focus();
}
// Main render function
function render(containerId, options) {
var opts = deepMerge(DEFAULTS, options || {});
if (!opts.token) {
console.error("AF.render: token is required");
return;
}
var container =
typeof containerId === "string"
? document.getElementById(containerId)
: containerId;
if (!container) {
console.error("AF.render: container not found (" + containerId + ")");
return;
}
// Track impression
trackImpression(opts.token);
// Initialize session tracking
var visitorId = getVisitorId(opts.token);
// Show loading state
var formId = generateId();
container.innerHTML =
'<div class="af-loading" style="text-align:center;padding:40px;color:#999;">Loading form...</div>';
// Fetch and render
fetchConfig(opts.token, function (err, config) {
if (err) {
container.innerHTML =
'<div style="text-align:center;padding:40px;color:#dc3545;">' +
escapeHtml("Could not load form: " + err.message) +
"</div>";
if (typeof opts.onError === "function") opts.onError(err);
return;
}
// Check for A/B test variants
fetchVariant(opts.token, visitorId, function (vErr, vResp) {
var assignedVariant = vResp && vResp.assigned_variant;
// Initialize session tracking with variant info
initSessionTracking(opts.token, assignedVariant ? assignedVariant.variant_key : null);
// If variant assigned, use variant's field_config
if (assignedVariant && assignedVariant.field_config && assignedVariant.field_config.length > 0) {
// Deep merge: variant config overrides base config fields
config = deepMerge(config, {
fields: assignedVariant.field_config,
success_message: assignedVariant.success_message || config.success_message,
});
// Also merge multi-step if present
if (assignedVariant.field_config.steps) {
config.steps = assignedVariant.field_config.steps;
}
console.log("[AF] A/B Test: Assigned variant", assignedVariant.variant_key);
}
// Build theme
var baseTheme =
THEMES[opts.theme] || THEMES.light;
var theme = deepMerge(baseTheme, opts.colors || {});
// Create form
var form = document.createElement("form");
form.className = "af-form";
form.id = formId;
form.setAttribute("data-af-token", opts.token);
form.noValidate = true;
// Inject CSS
var style = document.createElement("style");
style.id = "af-style-" + formId;
style.textContent = buildCss(theme);
document.head.appendChild(style);
// Multi-step support
var isMultiStep = config.multi_step && config.steps && Object.keys(config.steps).length > 1;
var stepKeys = isMultiStep ? Object.keys(config.steps).sort(function(a, b) { return parseInt(a) - parseInt(b); }) : [];
var currentStep = 0;
var totalSteps = stepKeys.length;
// Progress bar
var progressEl = null;
var progressLabel = null;
if (isMultiStep) {
progressEl = document.createElement("div");
progressEl.className = "af-progress";
var progressBar = document.createElement("div");
progressBar.className = "af-progress-bar";
for (var s = 0; s < totalSteps; s++) {
var segment = document.createElement("div");
segment.className = "af-progress-segment";
segment.setAttribute("data-step", s);
progressBar.appendChild(segment);
}
progressEl.appendChild(progressBar);
progressLabel = document.createElement("div");
progressLabel.className = "af-progress-label";
progressEl.appendChild(progressLabel);
form.appendChild(progressEl);
}
function updateProgress() {
if (!progressEl || !progressLabel) return;
var segments = progressBar.querySelectorAll(".af-progress-segment");
for (var i = 0; i < segments.length; i++) {
segments[i].className = "af-progress-segment";
if (i < currentStep) segments[i].classList.add("done");
if (i === currentStep) segments[i].classList.add("active");
}
progressLabel.textContent = opts.stepLabel.replace("{current}", currentStep + 1).replace("{total}", totalSteps);
}
// Create step containers
var stepContainers = {};
if (isMultiStep) {
for (var sk = 0; sk < stepKeys.length; sk++) {
var stepDiv = document.createElement("div");
stepDiv.className = "af-step";
stepDiv.setAttribute("data-step", sk);
form.appendChild(stepDiv);
stepContainers[stepKeys[sk]] = stepDiv;
}
// Render fields into their step containers
for (var sk2 = 0; sk2 < stepKeys.length; sk2++) {
var stepKey = stepKeys[sk2];
var stepFields = config.steps[stepKey];
if (stepFields && stepFields.length > 0) {
for (var fi = 0; fi < stepFields.length; fi++) {
var fieldEl = renderField(stepFields[fi], formId);
stepContainers[stepKey].appendChild(fieldEl);
}
}
}
} else {
// Single-step: render all fields directly
if (config.fields && config.fields.length > 0) {
for (var i = 0; i < config.fields.length; i++) {
var fieldEl = renderField(config.fields[i], formId);
form.appendChild(fieldEl);
}
}
}
// Conditional field rendering - wire up listeners after all fields are rendered
wireConditionListeners(form, config);
// Real-time validation feedback
wireRealTimeValidation(form, config, theme);
// Honeypot (hidden, always present)
if (config.honeypot_enabled !== false) {
form.appendChild(createHoneypot());
}
// Message area
var messageEl = createMessage();
form.appendChild(messageEl);
// Navigation container
var navEl = document.createElement("div");
navEl.className = isMultiStep ? "af-step-nav" : "";
// Previous button (multi-step only)
var prevBtn = null;
if (isMultiStep) {
prevBtn = createButton("button", opts.prevLabel);
prevBtn.className += " af-prev";
prevBtn.style.display = "none";
navEl.appendChild(prevBtn);
}
// Next/Submit button
var nextBtn = null;
var submitBtn = null;
if (isMultiStep) {
nextBtn = createButton("button", opts.nextLabel);
navEl.appendChild(nextBtn);
}
submitBtn = createButton("submit", opts.submitLabel);
if (isMultiStep) {
submitBtn.style.display = "none";
}
navEl.appendChild(submitBtn);
form.appendChild(navEl);
// Show/hide current step
function showStep(index) {
trackStepChange(index);
var allSteps = form.querySelectorAll(".af-step");
for (var i = 0; i < allSteps.length; i++) {
allSteps[i].classList.remove("active");
}
if (stepKeys[index]) {
stepContainers[stepKeys[index]].classList.add("active");
}
// Update buttons
if (prevBtn) {
prevBtn.style.display = index > 0 ? "" : "none";
}
if (nextBtn) {
nextBtn.style.display = index < totalSteps - 1 ? "" : "none";
if (nextBtn.querySelector(".af-btn-text")) {
nextBtn.querySelector(".af-btn-text").textContent = opts.nextLabel;
}
}
if (submitBtn) {
submitBtn.style.display = index === totalSteps - 1 ? "" : "none";
}
updateProgress();
// Clear step message
messageEl.style.display = "none";
}
// Collect form data
function collectData() {
var data = {};
var formData = new FormData(form);
for (var entry of formData.entries()) {
data[entry[0]] = entry[1];
}
return data;
}
// Show message
function showMessage(text, type) {
messageEl.textContent = text;
messageEl.style.display = "block";
messageEl.style.background = type === "error" ? theme.errorBg : theme.successBg;
messageEl.style.color = type === "error" ? theme.error : theme.success;
messageEl.style.border = "1px solid " + (type === "error" ? theme.error : theme.success);
}
// Disable form
function disableForm() {
var inputs = form.querySelectorAll("input, textarea, select, button");
for (var i = 0; i < inputs.length; i++) {
inputs[i].disabled = true;
}
}
// Previous button handler
if (prevBtn) {
prevBtn.addEventListener("click", function () {
if (currentStep > 0) {
currentStep--;
showStep(currentStep);
}
});
}
// Next button handler
if (nextBtn) {
nextBtn.addEventListener("click", function () {
var stepKey = stepKeys[currentStep];
var stepFields = config.steps[stepKey] || [];
var errors = validateStepFields(stepFields, form);
if (errors.length > 0) {
showFieldErrors(errors, form, theme);
showMessage(errors[0].message, "error");
return;
}
if (currentStep < totalSteps - 1) {
currentStep++;
showStep(currentStep);
}
});
}
// Submit handler
form.addEventListener("submit", function (e) {
e.preventDefault();
// Honeypot check
var honeypot = form.querySelector('[name="af_website"]');
if (honeypot && honeypot.value !== "") {
return; // Bot detected, silently drop
}
// If multi-step, validate all steps
if (isMultiStep) {
var allErrors = [];
for (var v = 0; v < stepKeys.length; v++) {
var sKey = stepKeys[v];
var sFields = config.steps[sKey] || [];
var stepErrors = validateStepFields(sFields, form);
allErrors = allErrors.concat(stepErrors);
}
if (allErrors.length > 0) {
showFieldErrors(allErrors, form, theme);
// Go to step with first error
var errorStepIdx = 0;
for (var es = 0; es < stepKeys.length; es++) {
var skk = stepKeys[es];
var skFields = config.steps[skk] || [];
for (var ef = 0; ef < skFields.length; ef++) {
if (skFields[ef].key === allErrors[0].field || skFields[ef].name === allErrors[0].field) {
errorStepIdx = es;
break;
}
}
if (errorStepIdx === es) break;
}
currentStep = errorStepIdx;
showStep(currentStep);
return;
}
} else {
// Single-step validation
var requiredFields = form.querySelectorAll("[required]");
for (var i = 0; i < requiredFields.length; i++) {
var field = requiredFields[i];
if (!field.value || (field.type === "checkbox" && !field.checked)) {
field.focus();
showMessage(field.dataset.label || "Please fill in this field.", "error");
return;
}
}
// Radio group validation
var radioGroups = form.querySelectorAll(".af-radio-group");
for (var j = 0; j < radioGroups.length; j++) {
var group = radioGroups[j];
var checked = group.querySelector('input[type="radio"]:checked');
if (!checked) {
showMessage("Please select an option.", "error");
return;
}
}
}
// Loading state
submitBtn.classList.add("loading");
submitBtn.disabled = true;
messageEl.style.display = "none";
// Build FormData
var formData = new FormData();
var fields = form.querySelectorAll('input[name], textarea[name], select[name]');
for (var k = 0; k < fields.length; k++) {
var f = fields[k];
if (f.name === "af_website") continue; // skip honeypot
if (f.type === "checkbox" && !f.checked) continue;
if (f.type === "radio" && !f.checked) continue;
formData.append(f.name, f.value);
}
// Submit
var xhr = new XMLHttpRequest();
xhr.open(
"POST",
config.submit_endpoint || (BASE + "/api/submit?token=" + opts.token),
true
);
xhr.timeout = 30000;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
submitBtn.classList.remove("loading");
if (xhr.status >= 200 && xhr.status < 300) {
disableForm();
showMessage(opts.successMessage, "success");
completeSession();
var submittedData = collectData();
if (typeof opts.onSubmit === "function") {
opts.onSubmit(submittedData);
}
} else {
showMessage(opts.errorMessage, "error");
submitBtn.disabled = false;
if (typeof opts.onError === "function") {
opts.onError(new Error("HTTP " + xhr.status));
}
}
}
};
xhr.onerror = function () {
submitBtn.classList.remove("loading");
submitBtn.disabled = false;
showMessage(opts.errorMessage, "error");
if (typeof opts.onError === "function") {
opts.onError(new Error("Network error"));
}
};
xhr.ontimeout = function () {
submitBtn.classList.remove("loading");
submitBtn.disabled = false;
showMessage("Request timed out. Please try again.", "error");
if (typeof opts.onError === "function") {
opts.onError(new Error("Timeout"));
}
};
xhr.send(formData);
});
// Initialize step visibility
if (isMultiStep) {
showStep(0);
} else {
// Clear progress label text for single step
if (progressLabel) progressLabel.textContent = "";
}
container.innerHTML = "";
container.appendChild(form);
}); // fetchVariant callback
}); // fetchConfig callback
return {
destroy: (function () {
return function () {
container.innerHTML = "";
var style = document.getElementById("af-style-" + formId);
if (style) style.remove();
};
})(),
};
}
// Expose globally
window.AF = {
render: render,
version: "2.0.0",
};
})(window, document);