DEV Community

Cover image for How We Built a Zero-Dependency 11-Platform Restaurant Fee Calculator (and Made It Embeddable)
MapToSite
MapToSite

Posted on

How We Built a Zero-Dependency 11-Platform Restaurant Fee Calculator (and Made It Embeddable)

A restaurant owner's pricing page says $219 a month. Her invoice, three years later, says something else. Nobody multiplies the monthly fee by 36 in front of her, and the parts that scale with her own sales — the per-order fees, the percentage of order value — are on a different page than the subscription.

That is the whole reason this calculator exists. Two sliders, eleven platforms, three years, each vendor's own published price. Here is how it is built, and what it took to make it embeddable on somebody else's blog.

The spread nobody shows you

At 250 online orders a month and a $30 average order — $7,500 a month in sales — the 2026 published prices come out like this over three years:

  • Slice Online Growth — $39/mo + $3.00 per card order: $28,404
  • Owner.com Flexible — $249/mo + 5% of order value: $22,464
  • Owner.com Flat Rate — $499/mo: $17,964
  • Toast Core — $219/mo + $1,153 countertop kit: $9,037
  • ChowNow Launch — $229/mo + $499 setup: $8,743
  • Lightspeed Essential — $189/mo: $6,804
  • Popmenu Starter — $179/mo: $6,444
  • BentoBox Essentials — $149/mo + $1,000 setup (last published list, 2023): $6,364
  • TouchBistro POS + online ordering — $69/mo + a reported $50/mo: $4,284
  • SpotOn POS Essentials — $55/station + $750 hardware: $2,730
  • Square Plus — $49/mo: $1,764

Same restaurant. Same 9,000 orders. A 16× spread, and every one of those numbers is the vendor's own figure, read from its own page on 11 November 2026.

Four numbers describe every bill

The interesting design problem is not the arithmetic. It is that eleven vendors sell in four different shapes, and the shapes are what make the bills incomparable on a pricing page.

So the data model is one array of vendors, each with plans, and a plan carries at most four numbers:

var VENDORS = [
  { id: "toast", name: "Toast", sub: "POS + online ordering",
    plans: [
      { id: "core",   label: "Core",   fee: 219, setup: 1153, def: true },
      { id: "growth", label: "Growth", fee: 578, setup: 1153 }
    ] },
  { id: "slice", name: "Slice", sub: "pizza ordering",
    plans: [
      { id: "online", label: "Online Growth", fee: 39, perOrder: 3.00, def: true }
    ] },
  { id: "owner", name: "Owner.com", sub: "direct ordering",
    plans: [
      { id: "flex", label: "Flexible",  fee: 249, pct: 0.05 },
      { id: "flat", label: "Flat Rate", fee: 499, def: true }
    ] }
];
Enter fullscreen mode Exit fullscreen mode

fee is dollars a month, setup is dollars once, perOrder is dollars per online order, pct is the share of order value the restaurant itself pays. Nothing else is a price field. If a vendor charges something that does not fit those four — card processing, a fee passed to the guest, an add-on it refuses to price — it goes in that vendor's excluded text and stays out of the total. A calculator that quietly adds up everything is a calculator that lies, and the fastest way to lose a reader is to be wrong by a line item they already know about.

One function for all of them

With four shapes, the total is four multiplications:

var MONTHS = 36;

function cost(plan, orders, avg) {
  var gmv = orders * avg;
  return {
    sub:      (plan.fee      || 0) * MONTHS,
    setup:    (plan.setup    || 0),
    perOrder: (plan.perOrder || 0) * orders * MONTHS,
    variable: (plan.pct      || 0) * gmv    * MONTHS
  };
}
Enter fullscreen mode Exit fullscreen mode

At 250 orders of $30:

  • Toast Core → $7,884 subscription + $1,153 kit = $9,037
  • Slice online → $1,404 subscription + $27,000 in $3 fees = $28,404
  • Owner.com Flexible → $8,964 subscription + $13,500 in 5% = $22,464

There is one piece of behaviour that looks like a bug and is not. Drag the order slider on Toast and the number does not move, because a subscription plus a hardware kit does not care how busy you were. That is the finding. It is also why the sliders exist: on Slice every extra order costs another $3, so the same slider that does nothing on Toast moves Slice by $1,080 a year for every 10 orders a month.

Zero dependencies, on purpose

The calculator is one HTML file: 65 KB uncompressed, including its CSS, its copy and the eleven-vendor table. No framework, no bundler output, no font request that blocks paint, no analytics beacon, no cookie, no network call at all after the HTML arrives. It runs the arithmetic in the browser and sends nothing anywhere.

That is partly principle and partly a hard requirement of being embedded: every dependency is something a publisher's Content-Security-Policy can block, and a widget that silently fails inside someone's blog post is worse than no widget. A single file with inline styles and inline script survives almost any policy a blog already has.

The state lives in the URL, so a reader can send the exact view to an accountant:

window.history.replaceState(null, "", "/calc?v=toast&p=core&n=250&a=30");
Enter fullscreen mode Exit fullscreen mode

Two range inputs, a query-string reader, ten lines of writer. No backend means no session, no GDPR question, and nothing to keep running.

Making it embeddable: same file, one flag

The widget is not a second build. It is /calc?embed=1, and everything embed-specific hangs off one class added before the first paint:

if (/[?&]embed=1(?:&|$)/.test(location.search)) {
  document.documentElement.className += " is-embedded";
}
Enter fullscreen mode Exit fullscreen mode

That inline script sits in <head>, so the class exists before any body markup is parsed — no flash of the site navigation before it disappears. The same block appends a robots: noindex meta tag, because the widget URL is a duplicate of the calculator and has no business in an index.

Then CSS does the removing:

html.is-embedded .nav,
html.is-embedded .footer,
html.is-embedded .buybar,
html.is-embedded .cx-sec:not(#calc) { display: none !important; }
Enter fullscreen mode Exit fullscreen mode

Header, footer, mobile buy bar and every section except the calculator itself. What is left is a compact hero line, the two-column tool, and one credit line at the very bottom: Free tool by ShopLaunch Studio · Full 11-Platform Breakdown. That credit is the entire price of using it, and it is why the thing can be free.

Three details were worth the effort:

  1. It fits the iframe. Two columns from 680 px, a sticky result column, tightened padding: measured 643–661 px tall at 760–865 px wide, so the recommended height="720" never scrolls. A widget that scrolls inside a frame reads as broken.
  2. The call to action leaves the frame. The in-page button links to #pricing, a section the widget deliberately does not render. In embed mode it is rewritten to the full site with a UTM tag and target="_blank".
  3. The URL keeps its flag. The calculator rewrites its query string on every slider move. Without a guard, the first drag inside a blog post would turn the iframe address into the full calculator — a reload would then drop embed mode entirely.

The published snippet is one line:

<iframe src="https://shoplaunch-app.pages.dev/calc?embed=1" width="100%" height="720"
        style="border:none;border-radius:12px;overflow:hidden;" loading="lazy"></iframe>
Enter fullscreen mode Exit fullscreen mode

The header that would have broken every embed

This is the part I would tell anyone building an embeddable tool.

The site sends X-Frame-Options: SAMEORIGIN and Content-Security-Policy: … frame-ancestors 'self' on every page. Both are correct for a checkout and a contact form. Both are fatal for a widget: the browser refuses to render the frame, and the publisher sees an empty box with no error they can read, while your page looks perfectly fine when you open it directly.

So the framing policy has to be per-route. Static hosts let you set headers globally, but the calculator is served by a worker that already rewrites pretty URLs (/calc → calculator.html), so the fix lives there:

delete one header and rewrite one directive, only for that file:

if (/\/calculator\.html$/.test(pathname)) {
  headers.delete("x-frame-options");
  const csp = headers.get("content-security-policy") || "";
  if (csp) headers.set(
    "content-security-policy",
    csp.replace(/frame-ancestors[^;]*/, "frame-ancestors *")
  );
}
Enter fullscreen mode Exit fullscreen mode

Then check it from outside, because a header you have not seen is a header you have not shipped:

curl -sI 'https://shoplaunch-app.pages.dev/calc?embed=1' | grep -i frame
# content-security-policy: … frame-ancestors *; …
# x-frame-options: (gone)
Enter fullscreen mode Exit fullscreen mode

The lesson: "make it embeddable" sounds like a layout task and is really a security-header task. Do it before you post the snippet, or the first person who tries it will assume your tool is broken.

What it costs to run

A static HTML file at the edge: no database, no API, no build minutes, no JS bundle to cache-bust. At this size the bill is a rounding error on zero, and the hundredth blog that embeds it costs the same as the first. The vendor price table took far longer to verify than the code took to write, which is the correct ratio for a tool whose only real asset is being right.

Try it, or take it

Live calculator, with the eleven platforms at your own numbers:
https://shoplaunch-app.pages.dev/calc?utm_source=devto&utm_medium=showdev&utm_campaign=2026-11-calculator-widget

If you write about restaurant tech, POS systems or local business software, the embed code above is yours — free, no sign-up, and the credit line is the only ask. It works in WordPress, Ghost, Squarespace, Webflow, any Markdown site that allows raw HTML, and Substack via an HTML block.

Disclosure: I build ShopLaunch Studio, the $199 one-time page the calculator compares against. The platform prices are those vendors' own, read on 11 November 2026, and each is linked with its read date at the foot of the calculator and on every comparison page. The $199 is mine. If a number is wrong, tell me and I will correct it with the source.

Top comments (0)