From 535dbda6b48171f8115b455e54790de94d97d42a Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 16:41:48 -0300 Subject: Initial import --- assets/js/cepis-autofill.min.js | 1 + assets/js/cepis-woocommerce-blocks.js | 243 +++++++++++++++++++++++++++++++++ assets/js/cepis-woocommerce-classic.js | 167 ++++++++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 assets/js/cepis-autofill.min.js create mode 100644 assets/js/cepis-woocommerce-blocks.js create mode 100644 assets/js/cepis-woocommerce-classic.js (limited to 'assets/js') diff --git a/assets/js/cepis-autofill.min.js b/assets/js/cepis-autofill.min.js new file mode 100644 index 0000000..1cb513e --- /dev/null +++ b/assets/js/cepis-autofill.min.js @@ -0,0 +1 @@ +(()=>{var{defineProperty:D,getOwnPropertyNames:E,getOwnPropertyDescriptor:h}=Object,f=Object.prototype.hasOwnProperty;function m(V){return this[V]}var g=(V)=>{var K=(M??=new WeakMap).get(V),J;if(K)return K;if(K=D({},"__esModule",{value:!0}),V&&typeof V==="object"||typeof V==="function"){for(var B of E(V))if(!f.call(K,B))D(K,B,{get:m.bind(V,B),enumerable:!(J=h(V,B))||J.enumerable})}return M.set(V,K),K},M;var c=(V)=>V;function u(V,K){this[V]=c.bind(null,K)}var p=(V,K)=>{for(var J in K)D(V,J,{get:K[J],enumerable:!0,configurable:!0,set:u.bind(K,J)})};var s={};p(s,{default:()=>o});var y="0.1.0";var k=/\D+/g,d=/[\u0300-\u036f]/g,l=["cep","logradouro","complemento","unidade","bairro","localidade","uf","estado","regiao","ibge","gia","ddd","siafi"],Q={idle:"",loading:"Buscando endereço…",found:"Endereço encontrado.",not_found:"CEP não encontrado. Confira o número ou preencha o endereço manualmente.",invalid:"CEP inválido. Digite os 8 dígitos.",rate_limited:"Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.",server:"O serviço de CEP está indisponível. Preencha o endereço manualmente.",network:"Não foi possível consultar o CEP. Verifique sua conexão e tente de novo.",timeout:"A consulta do CEP demorou demais. Tente de novo ou preencha manualmente.",invalid_response:"Resposta inesperada do serviço de CEP. Preencha o endereço manualmente."};class L extends Error{constructor(V,K,J={}){super(K,J.cause===void 0?void 0:{cause:J.cause});this.name="CepisAutofillError",this.code=V,this.status=J.status,this.retryAfterMs=J.retryAfterMs,this.cep=J.cep}}function U(V){let K=String(V==null?"":V).replace(k,"");if(K.length!==8)throw new L("invalid",Q.invalid,{cep:K});return K}var R=new Map;function b(){R.clear()}function a(V){if(!V)return;let K=V.trim();if(/^\d+$/.test(K))return Number(K)*1000;let J=Date.parse(K);return Number.isNaN(J)?void 0:Math.max(0,J-Date.now())}function n(V,K){try{let J=JSON.parse(V);if(J&&typeof J.mensagem==="string"&&J.mensagem!=="")return J.mensagem}catch{return K}return K}async function G(V,K={}){let J=U(V),B=(K.baseUrl??"https://cep.is").replace(/\/+$/,""),N=`${B}|${J}`,q=R.get(N);if(q){if(q.address)return q.address;throw new L(q.code,q.message,{cep:J})}let F=new AbortController,Y=()=>F.abort();if(K.signal){if(K.signal.aborted)throw new L("aborted","consulta cancelada",{cep:J});K.signal.addEventListener("abort",Y)}let j=!1,_=setTimeout(()=>{j=!0,F.abort()},K.timeoutMs??8000),H,O;try{H=await fetch(`${B}/${J}`,{method:"GET",credentials:"omit",headers:{Accept:"application/json"},signal:F.signal}),O=await H.text()}catch(W){if(j)throw new L("timeout",Q.timeout,{cep:J,cause:W});if(F.signal.aborted)throw new L("aborted","consulta cancelada",{cep:J});throw new L("network",Q.network,{cep:J,cause:W})}finally{if(clearTimeout(_),K.signal)K.signal.removeEventListener("abort",Y)}if(H.status===404)throw R.set(N,{code:"not_found",message:Q.not_found}),new L("not_found",Q.not_found,{cep:J});if(H.status===400)throw new L("invalid",n(O,Q.invalid),{cep:J});if(H.status===429)throw new L("rate_limited",Q.rate_limited,{cep:J,retryAfterMs:a(H.headers.get("Retry-After"))});if(H.status>=500)throw new L("server",Q.server,{cep:J,status:H.status});if(!H.ok)throw new L("invalid_response",Q.invalid_response,{cep:J,status:H.status});let z;try{z=JSON.parse(O)}catch(W){throw new L("invalid_response",Q.invalid_response,{cep:J,cause:W})}if(!z||typeof z!=="object"||Array.isArray(z))throw new L("invalid_response",Q.invalid_response,{cep:J});return R.set(N,{address:z}),z}function T(V){if(!V)return null;if(typeof V==="string")return document.querySelector(V);return V.nodeType===1?V:null}function I(V){return String(V??"").normalize("NFD").replace(d,"").trim().toLowerCase()}function C(V,K){let J=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(V),"value");if(J&&J.set)J.set.call(V,K);else V.value=K;V.dispatchEvent(new Event("input",{bubbles:!0})),V.dispatchEvent(new Event("change",{bubbles:!0}))}function r(V,K){let J=K.filter(Boolean).map(I);for(let B of Array.from(V.options))if(J.includes(I(B.value))||J.includes(I(B.textContent)))return C(V,B.value),B.value;return null}function i(V,K,J){let B=String(V.value??"");if(K===!0)return!0;if(B.trim()==="")return!0;if(K===!1)return!1;return J.get(V)===B}var w=0;function A(V){if(!V||!V.cep)throw TypeError("attach() needs a `cep` selector or element");let K=(V.baseUrl??"https://cep.is").replace(/\/+$/,""),J=V.debounceMs??250,B=V.timeoutMs??8000,N=V.overwrite??"auto",q={...Q,...V.messages??{}},F=new WeakMap,Y={seq:0,timer:0,controller:null,region:null,describedBy:!1,detached:!1},j=()=>T(V.cep);function _(){if(V.status===!1)return null;if(Y.region&&Y.region.isConnected)return Y.region;if(V.status)Y.region=T(V.status);else{let $=j();if(!$)return null;let x=document.createElement("div");x.className="cepis-autofill-status",x.style.minHeight="1.25em",$.insertAdjacentElement("afterend",x),Y.region=x}let X=Y.region;if(!X)return null;if(X.setAttribute("role","status"),X.setAttribute("aria-live","polite"),X.setAttribute("aria-atomic","true"),!X.id)w+=1,X.id=`cepis-autofill-status-${w}`;let Z=j();if(Z&&!(Z.getAttribute("aria-describedby")??"").includes(X.id)){let $=Z.getAttribute("aria-describedby");Z.setAttribute("aria-describedby",$?`${$} ${X.id}`:X.id),Y.describedBy=!0}return X}function H(X){let Z=q[X]??"",$=_();if($)$.setAttribute("data-cepis-state",X==="found"||X==="loading"?X:X==="idle"?"idle":"error"),$.textContent=Z;let x=j();if(x){if(X==="loading")x.setAttribute("aria-busy","true");else x.removeAttribute("aria-busy");x.setAttribute("aria-invalid",X==="invalid"||X==="not_found"?"true":"false")}if(V.onStatus)V.onStatus(X,Z)}function O(X){for(let Z of l){if(Z==="cep"||!V[Z])continue;let $=T(V[Z]),x=X[Z];if(!$||typeof x!=="string"||x==="")continue;if(!i($,N,F))continue;if($.tagName==="SELECT"){if(r($,Z==="uf"?[x,X.estado]:Z==="estado"?[x,X.uf]:[x])!==null)F.set($,$.value);continue}C($,x),F.set($,x)}}async function z(X){Y.seq+=1;let Z=Y.seq;if(Y.controller)Y.controller.abort();let $=new AbortController;Y.controller=$,H("loading");try{let x=await G(X,{baseUrl:K,timeoutMs:B,signal:$.signal});if(Z!==Y.seq||Y.detached)return null;if(O(x),H("found"),V.onResult)V.onResult(x,{cep:X});let P=T(V.numero);if(V.focusNumero&&P&&document.activeElement===j())P.focus();return x}catch(x){if(Z!==Y.seq||Y.detached||x.code==="aborted")return null;let P=x instanceof L?x.code:"network";if(H(P),V.onError)V.onError(x,{cep:X});return null}finally{if(Y.controller===$)Y.controller=null}}function W(X){let Z=X.target;if(!(typeof V.cep==="string"?Z&&Z.nodeType===1&&Z.matches(V.cep):Z===V.cep))return;v(String(Z.value??""))}function v(X){clearTimeout(Y.timer);let Z=X.replace(k,"");if(Z.length!==8){if(Y.seq+=1,Y.controller)Y.controller.abort(),Y.controller=null;H(Z.length>8?"invalid":"idle");return}Y.timer=setTimeout(()=>z(Z),J)}return H("idle"),document.addEventListener("input",W,!0),document.addEventListener("change",W,!0),{detach(){if(Y.detached=!0,Y.seq+=1,clearTimeout(Y.timer),Y.controller)Y.controller.abort(),Y.controller=null;document.removeEventListener("input",W,!0),document.removeEventListener("change",W,!0);let X=j();if(X)X.removeAttribute("aria-busy"),X.removeAttribute("aria-invalid");if(Y.region&&Y.describedBy&&X){let Z=(X.getAttribute("aria-describedby")??"").split(/\s+/).filter(($)=>$&&$!==Y.region.id).join(" ");if(Z)X.setAttribute("aria-describedby",Z);else X.removeAttribute("aria-describedby")}if(Y.region&&!V.status)Y.region.remove();Y.region=null},lookup(X){clearTimeout(Y.timer);let Z=j(),$=X??(Z?Z.value:""),x;try{x=U($)}catch(P){if(H("invalid"),V.onError)V.onError(P,{cep:String($)});return Promise.resolve(null)}return z(x)},element:j}}var S={attach:A,lookup:G,normalizeCep:U,clearCache:b,CepisAutofillError:L,MESSAGES:Q,VERSION:y};if(typeof window<"u")window.CepisAutofill=S;var o=S;})(); diff --git a/assets/js/cepis-woocommerce-blocks.js b/assets/js/cepis-woocommerce-blocks.js new file mode 100644 index 0000000..bcbab93 --- /dev/null +++ b/assets/js/cepis-woocommerce-blocks.js @@ -0,0 +1,243 @@ +/** + * WooCommerce Cart/Checkout Blocks. + * + * The block checkout is React and its address inputs are controlled: a value + * written into the DOM from outside is discarded on the next render, and the + * `wc/store/cart` data store — which is what the order is actually built from — + * never hears about it. So nothing here touches a field. The postcode is read + * from the data store, and the resolved address is written back through the + * store's own `setShippingAddress` / `setBillingAddress` actions, which is the + * documented extension point. React then re-renders the inputs itself. + * + * Only the request layer is shared with the classic integration: + * `CepisAutofill.lookup` is the widget's HTTP client without its DOM half — + * same cache, same error codes, same handling of 400, 404, 429 and 5xx. + * + * @module + */ + +(function () { + var config = window.cepisWooCommerce; + var data = window.wp && window.wp.data; + + if (!config || !config.fields || !window.CepisAutofill || !data || !data.select) { + return; + } + + var CART = "wc/store/cart"; + var NOTICES = "core/notices"; + + /** + * Where a notice has to be rendered for the Checkout block to show it. + */ + var NOTICE_CONTEXT = "wc/checkout"; + + /** + * One id, so a new failure replaces the previous message instead of stacking. + */ + var NOTICE_ID = "cepis-woocommerce-lookup"; + + var SECTIONS = { + shipping: { address: "shippingAddress", write: "setShippingAddress" }, + billing: { address: "billingAddress", write: "setBillingAddress" }, + }; + + var progress = { + shipping: { cep: "", timer: 0, written: {} }, + billing: { cep: "", timer: 0, written: {} }, + }; + + /** + * The one field the shopper is never asked about. + * + * A street name is worth protecting — the shopper may know better than the + * database. A state is not: the CEP decides it. WooCommerce also starts the + * checkout with the store's own state already selected, which the `"auto"` + * rule would read as the shopper's choice and never touch, quietly filing a + * Rio address under São Paulo. + */ + var ALWAYS = "uf"; + + /** + * Whether a field may be written. + * + * Mirrors the widget's `overwrite` option: `"auto"` protects anything the + * shopper typed but replaces what a previous lookup wrote, so correcting the + * street by hand survives the next CEP. + * + * @param {string} field The API's name for the field. + * @param {string} current Value the store holds now. + * @param {string|undefined} written Value this plugin last wrote there. + * @returns {boolean} + */ + function writable(field, current, written) { + if (field === ALWAYS || config.overwrite === true) { + return true; + } + if (current.trim() === "") { + return true; + } + if (config.overwrite === false) { + return false; + } + return written === current; + } + + var pending = {}; + + /** + * Look a CEP up once, however many sections want it. + * + * With "use the same address for billing" on — the default — the shipping + * and the billing observer see the same postcode land in the same tick. The + * widget's own cache is only filled once a response is back, so without this + * the store would send two identical requests for every CEP a shopper types. + * + * @param {string} digits The eight digits. + * @returns {Promise>} + */ + function resolve(digits) { + if (!pending[digits]) { + var forget = function () { + delete pending[digits]; + }; + + pending[digits] = window.CepisAutofill.lookup(digits, { + baseUrl: config.baseUrl, + timeoutMs: config.timeoutMs, + }); + pending[digits].then(forget, forget); + } + + return pending[digits]; + } + + /** + * Show one message in the Checkout block's own notice area. + * + * @param {string} text Message, already translated. + */ + function announce(text) { + data.dispatch(NOTICES).createNotice("error", text, { + id: NOTICE_ID, + context: NOTICE_CONTEXT, + isDismissible: true, + }); + } + + /** Take the message away. */ + function silence() { + data.dispatch(NOTICES).removeNotice(NOTICE_ID, NOTICE_CONTEXT); + } + + /** + * Write a resolved address into the store. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {Record} address The API's answer. + */ + function apply(name, address) { + var customer = data.select(CART).getCustomerData(); + var current = (customer && customer[SECTIONS[name].address]) || {}; + var written = progress[name].written; + var patch = {}; + var changed = false; + + Object.keys(config.fields).forEach(function (field) { + var key = config.fields[field]; + var value = address[field]; + + if (!key || typeof value !== "string" || value === "") { + return; + } + + var held = current[key] == null ? "" : String(current[key]); + if (!writable(field, held, written[key])) { + return; + } + + patch[key] = value; + written[key] = value; + changed = true; + }); + + if (changed) { + data.dispatch(CART)[SECTIONS[name].write](patch); + } + } + + /** + * Resolve one CEP and write what comes back. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {string} digits The eight digits. + */ + function run(name, digits) { + silence(); + + resolve(digits).then( + function (address) { + if (progress[name].cep === digits) { + apply(name, address); + } + }, + function (error) { + if (progress[name].cep !== digits || error.code === "aborted") { + return; + } + announce(config.messages[error.code] || error.message); + }, + ); + } + + /** + * React to whatever the store now holds for one address. + * + * @param {string} name Section name, `shipping` or `billing`. + * @param {Record|undefined} address Address from the store. + */ + function observe(name, address) { + var seen = progress[name]; + + if (!address || String(address.country || "") !== "BR") { + return; + } + + var digits = String(address.postcode || "").replace(/\D+/g, ""); + + if (digits.length !== 8) { + if (seen.cep !== "") { + seen.cep = ""; + window.clearTimeout(seen.timer); + silence(); + } + return; + } + + if (digits === seen.cep) { + return; + } + + seen.cep = digits; + window.clearTimeout(seen.timer); + seen.timer = window.setTimeout(function () { + run(name, digits); + }, config.debounceMs); + } + + data.subscribe(function () { + var store = data.select(CART); + if (!store || typeof store.getCustomerData !== "function") { + return; + } + + var customer = store.getCustomerData(); + if (!customer) { + return; + } + + Object.keys(SECTIONS).forEach(function (name) { + observe(name, customer[SECTIONS[name].address]); + }); + }, CART); +})(); diff --git a/assets/js/cepis-woocommerce-classic.js b/assets/js/cepis-woocommerce-classic.js new file mode 100644 index 0000000..69d8068 --- /dev/null +++ b/assets/js/cepis-woocommerce-classic.js @@ -0,0 +1,167 @@ +/** + * Classic (shortcode) WooCommerce checkout. + * + * The form is rendered by PHP and nothing owns the fields afterwards, so the + * widget is left to do the whole job: debounce, request, write, announce. Most + * of this file is handing it the selectors WooCommerce uses and the settings + * the store owner chose. + * + * Selectors are passed as strings rather than elements on purpose. The widget + * resolves them on every use and delegates its listener from `document`, so a + * theme or one-page-checkout plugin that re-renders the billing form does not + * break the autofill. + * + * @module + */ + +(function () { + var config = window.cepisWooCommerce; + + if (!config || !window.CepisAutofill || !config.fields) { + return; + } + + var DIACRITIC = /[\u0300-\u036f]/g; + + /** + * Compare labels the way a person would: accents and case do not count. + * + * @param {unknown} value Anything printable. + * @returns {string} + */ + function fold(value) { + return String(value == null ? "" : value) + .normalize("NFD") + .replace(DIACRITIC, "") + .trim() + .toLowerCase(); + } + + /** + * Set a value the way a person would, so validation in the page sees it. + * + * @param {HTMLInputElement|HTMLSelectElement} element Field to write. + * @param {string} value Value to put in it. + */ + function write(element, value) { + var descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value"); + + if (descriptor && descriptor.set) { + descriptor.set.call(element, value); + } else { + element.value = value; + } + + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + } + + /** + * Fill the state field. + * + * This one field is kept away from the widget, because it needs the opposite + * of the widget's `overwrite` rule. A street name is worth protecting — the + * shopper may know better than the database. A state is not: the CEP decides + * it. And WooCommerce pre-selects the store's own state on a fresh checkout, + * which `overwrite: "auto"` would read as a choice the shopper made and + * never touch, quietly filing a Rio address under São Paulo. + * + * Nothing is cleared when no option matches, and this runs only after a + * successful lookup, so a CEP that does not exist leaves the field alone. + * + * Focus is put back where it was. WooCommerce runs the state field through + * selectWoo, which takes focus for itself when the value changes, and that + * would otherwise strand the cursor on the state box instead of the house + * number the shopper still has to type. + * + * @param {string} selector CSS selector for the state field. + * @param {string} uf Two-letter state code from the API. + * @param {string} estado Full state name from the API. + */ + function fillState(selector, uf, estado) { + var element = selector ? document.querySelector(selector) : null; + + if (!element || !uf) { + return; + } + + var value = uf; + + if ("SELECT" === element.tagName) { + var wanted = [uf, estado].filter(Boolean).map(fold); + var options = Array.prototype.slice.call(element.options); + var match = null; + + for (var i = 0; i < options.length; i += 1) { + if ( + wanted.indexOf(fold(options[i].value)) !== -1 || + wanted.indexOf(fold(options[i].textContent)) !== -1 + ) { + match = options[i].value; + break; + } + } + + if (null === match) { + return; + } + + value = match; + } + + if (element.value === value) { + return; + } + + var active = document.activeElement; + + write(element, value); + + if (active && active !== document.activeElement && "function" === typeof active.focus) { + active.focus(); + } + } + + /** + * Attach one address section — billing or shipping. + * + * @param {Record} map Selectors, keyed by the API's field names. + * @returns {object|null} The widget handle, or null when there is no CEP field. + */ + function section(map) { + if (!map || !map.cep) { + return null; + } + + var options = { + baseUrl: config.baseUrl, + overwrite: config.overwrite, + focusNumero: !!config.focusNumero, + debounceMs: config.debounceMs, + timeoutMs: config.timeoutMs, + messages: config.messages, + onResult: function (address) { + fillState(map.uf, address.uf, address.estado); + }, + }; + + Object.keys(map).forEach(function (field) { + if ("uf" !== field) { + options[field] = map[field]; + } + }); + + return window.CepisAutofill.attach(options); + } + + var handles = []; + + Object.keys(config.fields).forEach(function (name) { + var handle = section(config.fields[name]); + if (handle) { + handles.push(handle); + } + }); + + window.cepisWooCommerceHandles = handles; +})(); -- cgit v1.3