<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tailwebs Technology]]></title><description><![CDATA[Tailwebs Technology]]></description><link>https://tailwebs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aed89f7d3135c29123c6151/0c77eb18-38f0-4538-b9f8-670a2d913446.jpg</url><title>Tailwebs Technology</title><link>https://tailwebs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 14 Nov 2026 19:07:13 GMT</lastBuildDate><atom:link href="https://tailwebs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[One script tag, four site builders: adding a chat agent to Shopify, WordPress, Webflow and Wix (and keeping it on your own domain)]]></title><description><![CDATA[Where to paste one script tag on Shopify, WordPress, Webflow and Wix, and how to keep a chat widget working only on your own domain.]]></description><link>https://tailwebs.hashnode.dev/one-script-tag-four-site-builders-adding-a-chat-agent-to-shopify-wordpress-webflow-and-wix-and-keeping-it-on-your-own-domain</link><guid isPermaLink="true">https://tailwebs.hashnode.dev/one-script-tag-four-site-builders-adding-a-chat-agent-to-shopify-wordpress-webflow-and-wix-and-keeping-it-on-your-own-domain</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[WordPress]]></category><category><![CDATA[shopify]]></category><category><![CDATA[#chatbots]]></category><dc:creator><![CDATA[Tailwebs Technology]]></dc:creator><pubDate>Fri, 13 Nov 2026 06:52:23 GMT</pubDate><content:encoded><![CDATA[<p>Most chat widgets install the same way: you paste one <code>&lt;script&gt;</code> tag into your site and a chat bubble appears in the corner. The pasting is easy. The harder parts are finding the right place on each site builder, and making sure the snippet works only on your own domain, because anyone can copy a script tag from your page source.</p>
<p>This post covers both. The examples use placeholders, so swap in the values your widget gives you.</p>
<h2>The snippet</h2>
<p>A typical embed looks like this:</p>
<pre><code class="language-html">&lt;script
  src="YOUR_WIDGET_SCRIPT_URL"
  data-bot-id="YOUR_BOT_ID"
  defer&gt;&lt;/script&gt;
</code></pre>
<p>Two things matter. <code>defer</code> stops the widget from blocking your page while it loads. And the snippet should go just before <code>&lt;/body&gt;</code>, or in the site builder's "footer code" box, so your own content loads first.</p>
<h2>Shopify</h2>
<ol>
<li>In the admin, go to <strong>Online Store &gt; Themes</strong>.</li>
<li>On your live theme, open the menu next to <strong>Customize</strong> and choose <strong>Edit code</strong>.</li>
<li>Open <code>layout/theme.liquid</code>.</li>
<li>Paste the snippet on the line just above <code>&lt;/body&gt;</code> and save.</li>
</ol>
<p>Theme updates can replace <code>theme.liquid</code>, so keep a note of the change. Shopify's checkout pages do not load your theme layout, so don't expect the bubble to show there.</p>
<h2>WordPress</h2>
<p>For a classic theme, the cleanest way without an extra plugin is a small hook in your child theme's <code>functions.php</code>:</p>
<pre><code class="language-php">add_action( 'wp_footer', function () {
    echo '&lt;script src="YOUR_WIDGET_SCRIPT_URL" data-bot-id="YOUR_BOT_ID" defer&gt;&lt;/script&gt;';
} );
</code></pre>
<p>Use a child theme. If you edit the parent theme, the next theme update wipes the change. If you already use a header-and-footer code plugin, its footer box works too.</p>
<h2>Webflow</h2>
<ol>
<li>Open <strong>Site settings &gt; Custom code</strong>.</li>
<li>Paste the snippet into <strong>Footer code</strong>.</li>
<li>Save, then <strong>publish</strong> the site. Webflow only adds custom code to the published site, not the Designer preview.</li>
</ol>
<p>To show the bubble on one page only, use that page's own settings (Custom code, before <code>&lt;/body&gt;</code> tag) instead.</p>
<h2>Wix</h2>
<ol>
<li>Go to <strong>Settings &gt; Custom code</strong> (under Advanced).</li>
<li>Click <strong>Add custom code</strong> and paste the snippet.</li>
<li>Choose <strong>All pages</strong>, set the place to <strong>Body - end</strong>, and apply.</li>
</ol>
<p>Wix only runs custom code on a published site with a connected domain, so test it on the live address, not in the editor.</p>
<h2>Keep it to your own domain</h2>
<p>Your snippet is public. Someone can copy it onto their own site and start using your question allowance. The check that matters runs on the widget's server, not in the browser.</p>
<p>When the widget calls its API from a page, the browser sends an <code>Origin</code> header naming the site. The server compares it with an allowlist:</p>
<pre><code class="language-js">// Each entry is a full origin: scheme plus host, no path.
const ALLOWED_ORIGINS = new Set([
  process.env.SITE_ORIGIN,      // your bare domain
  process.env.SITE_ORIGIN_WWW,  // the same domain with www in front
]);

function isAllowedOrigin(req) {
  const origin = req.headers.origin;
  return typeof origin === 'string' &amp;&amp; ALLOWED_ORIGINS.has(origin);
}

// in the handler
if (!isAllowedOrigin(req)) {
  return res.status(403).json({ error: 'origin not allowed' });
}
</code></pre>
<p>Three details catch people out:</p>
<ul>
<li><strong>List every form of your address.</strong> Your bare domain and its www version are different origins, and so are the plain and secure (TLS) versions of each.</li>
<li><strong>Don't trust a check in the browser.</strong> A widget script that reads <code>window.location.hostname</code> and refuses to start is easy to remove from a copied page.</li>
<li><strong>The</strong> <code>Origin</code> <strong>header stops browsers, not scripts.</strong> A script outside a browser can send any header it likes, so add a rate limit per bot as well. An allowlist plus a limit on questions per minute covers both the copied snippet and the script.</li>
</ul>
<h2>Check it works</h2>
<p>After publishing, open your live site in a private window and ask the bubble a question your pages answer, such as "Are you open on Saturday?". Then paste the same snippet into a local HTML file and open it: the widget should refuse to answer there. If it answers in both places, your allowlist is too wide.</p>
<p>I work on TailGPT (<a href="https://tailgpt.tailwebs.com/?utm_source=hashnode.com&amp;utm_medium=article&amp;utm_campaign=adding-a-chat-agent-with-one-script-tag-on-shopify-wordpress&amp;utm_content=adding-a-chat-agent-with-one-script-tag-on-shopify-wordpress">https://tailgpt.tailwebs.com/?utm_source=hashnode.com&amp;utm_medium=article&amp;utm_campaign=adding-a-chat-agent-with-one-script-tag-on-shopify-wordpress&amp;utm_content=adding-a-chat-agent-with-one-script-tag-on-shopify-wordpress</a>), a website AI agent that installs this way: one script tag, no plugin, loading only on your own domain, with a questions-per-minute limit. The steps above work the same for any widget that gives you a script tag.</p>
<p>If your site builder isn't covered here, look for a box called "footer code", "custom code" or "before <code>&lt;/body&gt;</code>". That's almost always where the snippet goes.</p>
]]></content:encoded></item><item><title><![CDATA[Offline edits in a shared expense app: merging changes, keeping balances right and settling up once]]></title><description><![CDATA[Two people in one household edit the same grocery expense on the same evening. One is on a train with no signal and corrects the amount. The other is at…]]></description><link>https://tailwebs.hashnode.dev/offline-edits-in-a-shared-expense-app-merging-changes-keeping-balances-right-and-settling-up-once</link><guid isPermaLink="true">https://tailwebs.hashnode.dev/offline-edits-in-a-shared-expense-app-merging-changes-keeping-balances-right-and-settling-up-once</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[offline]]></category><category><![CDATA[sync]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[Tailwebs Technology]]></dc:creator><pubDate>Tue, 10 Nov 2026 07:22:44 GMT</pubDate><content:encoded><![CDATA[<p>Two people in one household edit the same grocery expense on the same evening. One is on a train with no signal and corrects the amount. The other is at home and adds a third person to the split. Later both phones come back online. The app now has to keep both changes, agree on what to do when they touch the same field, show the right "who owes whom" on every phone, and never count a settle-up twice.</p>
<p>I work on <a href="https://fincurry.com/?utm_source=hashnode.com&amp;utm_medium=article&amp;utm_campaign=real-time-sync-for-a-family-group-offline-edits-conflicts-an&amp;utm_content=real-time-sync-for-a-family-group-offline-edits-conflicts-an">Fincurry</a>, a family group and personal expense tracker that syncs in real time across the family and lets you split bills and settle up, so I think about this problem a lot. To be clear: the code below is a general illustration of the technique in plain JavaScript. It is not how Fincurry is built, and it does not describe its database or sync setup.</p>
<p>The idea fits in four rules:</p>
<ol>
<li>Store <strong>operations</strong>, not the current state.</li>
<li>Give every operation a unique <strong>id</strong> and a <strong>logical clock</strong>, so every phone sorts them the same way.</li>
<li>Merge <strong>per field</strong>, so edits to different fields never fight.</li>
<li><strong>Recompute balances</strong> from the operations instead of storing running totals, and make settle-up an operation with its own id, so a repeated sync cannot count it twice.</li>
</ol>
<h2>Step 1: an operation log with a logical clock</h2>
<p>Wall clocks on phones drift, so we do not trust them for ordering. A Lamport counter is enough here: each device increases its counter for every operation it makes, and jumps ahead whenever it sees a higher counter from another device. Ties are broken by the device name, so the order is the same everywhere.</p>
<pre><code class="language-js">// sync.js
'use strict';

function createDevice(device) {
  let counter = 0;
  let seq = 0;
  return {
    observe(ops) {
      for (const op of ops) counter = Math.max(counter, op.counter);
    },
    op(fields) {
      counter += 1;
      seq += 1;
      return { id: `${device}:${seq}`, device, counter, ...fields };
    },
  };
}

function compareStamp(a, b) {
  if (a.counter !== b.counter) return a.counter - b.counter;
  if (a.device === b.device) return 0;
  return a.device &lt; b.device ? -1 : 1;
}
</code></pre>
<h2>Step 2: merge logs by id</h2>
<p>Merging is a set union keyed by the operation id, then a sort. Receiving the same operation twice changes nothing, which is exactly what you want when a flaky connection retries an upload.</p>
<pre><code class="language-js">function mergeLogs(...logs) {
  const byId = new Map();
  for (const log of logs) for (const op of log) byId.set(op.id, op);
  return [...byId.values()].sort(compareStamp);
}
</code></pre>
<h2>Step 3: fold operations into expenses, one field at a time</h2>
<p>Each edit is a <code>set</code> operation on one field of one expense. Folding the sorted log means the latest write wins <strong>per field</strong>, not per expense. So the amount fixed on the train and the new split made at home both survive. Amounts are whole minor units (paise, cents) so no floating point creeps into the totals. Deleting is just setting <code>deleted</code> to true.</p>
<pre><code class="language-js">function foldExpenses(ops) {
  const expenses = new Map();
  for (const op of ops) {
    if (op.type !== 'set') continue;
    const current = expenses.get(op.expenseId) || {};
    expenses.set(op.expenseId, { ...current, [op.field]: op.value });
  }
  return expenses;
}
</code></pre>
<h2>Step 4: recompute balances, and treat settle-up as an operation</h2>
<p>A running total such as "Ravi owes 1,200" goes wrong the moment two offline edits arrive in a different order. Recomputing from the merged log always gives the same answer on every phone. A positive balance means the person is owed money. An equal split hands out the leftover minor units in a fixed order, so the balances always add up to zero.</p>
<pre><code class="language-js">function balances(ops) {
  const totals = {};
  const add = (person, amount) =&gt; {
    totals[person] = (totals[person] || 0) + amount;
  };
  for (const e of foldExpenses(ops).values()) {
    if (e.deleted || !e.amount) continue;
    const people = [...e.splitAmong].sort();
    const base = Math.floor(e.amount / people.length);
    let rest = e.amount - base * people.length;
    add(e.paidBy, e.amount);
    for (const person of people) {
      const extra = rest &gt; 0 ? 1 : 0;
      rest -= extra;
      add(person, -(base + extra));
    }
  }
  for (const op of ops) {
    if (op.type !== 'settle') continue;
    add(op.from, op.amount);
    add(op.to, -op.amount);
  }
  return totals;
}

module.exports = { createDevice, mergeLogs, foldExpenses, balances };
</code></pre>
<h2>Step 5: prove it with node:test</h2>
<pre><code class="language-js">// sync.test.js
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const { createDevice, mergeLogs, foldExpenses, balances } = require('./sync');

function createGroceries(device) {
  return [
    device.op({ type: 'set', expenseId: 'groceries', field: 'amount', value: 2400 }),
    device.op({ type: 'set', expenseId: 'groceries', field: 'paidBy', value: 'asha' }),
    device.op({ type: 'set', expenseId: 'groceries', field: 'splitAmong', value: ['asha', 'ravi'] }),
  ];
}

test('offline edits to different fields both survive', () =&gt; {
  const asha = createDevice('asha');
  const ravi = createDevice('ravi');
  const shared = createGroceries(asha);
  ravi.observe(shared);

  const ashaOffline = [asha.op({ type: 'set', expenseId: 'groceries', field: 'amount', value: 3000 })];
  const raviOffline = [ravi.op({ type: 'set', expenseId: 'groceries', field: 'splitAmong', value: ['asha', 'ravi', 'meera'] })];

  const merged = mergeLogs([...shared, ...ashaOffline], [...shared, ...raviOffline]);
  const expense = foldExpenses(merged).get('groceries');
  assert.strictEqual(expense.amount, 3000);
  assert.deepStrictEqual(expense.splitAmong, ['asha', 'ravi', 'meera']);
  assert.deepStrictEqual(balances(merged), { asha: 2000, ravi: -1000, meera: -1000 });
});

test('a conflict on the same field ends the same way on every phone', () =&gt; {
  const asha = createDevice('asha');
  const ravi = createDevice('ravi');
  const shared = createGroceries(asha);
  ravi.observe(shared);

  const a = [asha.op({ type: 'set', expenseId: 'groceries', field: 'amount', value: 2500 })];
  const r = [ravi.op({ type: 'set', expenseId: 'groceries', field: 'amount', value: 2600 })];

  const onAshaPhone = foldExpenses(mergeLogs([...shared, ...a], r)).get('groceries');
  const onRaviPhone = foldExpenses(mergeLogs([...shared, ...r], a)).get('groceries');
  assert.strictEqual(onAshaPhone.amount, onRaviPhone.amount);
});

test('a settle-up received twice is counted once', () =&gt; {
  const asha = createDevice('asha');
  const ravi = createDevice('ravi');
  const shared = createGroceries(asha);
  ravi.observe(shared);
  const settle = ravi.op({ type: 'settle', from: 'ravi', to: 'asha', amount: 1200 });

  const merged = mergeLogs([...shared, settle], [settle], [...shared, settle]);
  assert.deepStrictEqual(balances(merged), { asha: 0, ravi: 0 });
});
</code></pre>
<p>Run it with <code>node --test</code>. All three tests pass with no dependencies.</p>
<h2>What this does not solve</h2>
<p><strong>Same-field conflicts lose one value.</strong> Last writer wins is predictable, but one person's amount quietly disappears. For money fields it is often kinder to keep both operations, show the expense as "edited on two phones" and let a person pick. The log already has everything you need for that, because nothing is overwritten.</p>
<p><strong>Two people can both tap settle up.</strong> If both members record the same payment while offline, you get two operations with different ids, and the id check cannot catch that. A simple guard is to flag any settle-up that pushes a balance past zero, and ask instead of guessing.</p>
<p><strong>Logs grow.</strong> Once every device in the group has seen everything up to some counter, you can fold that part of the log into a snapshot and keep only the newer operations.</p>
<p>The main lesson for me: when the data is money shared by a household, don't store totals, store what happened. Totals you can always work out again. A lost edit you can't get back.</p>
]]></content:encoded></item></channel></rss>