<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Joshua</title>
    <description>The latest articles on Forem by Joshua (@joshmsn).</description>
    <link>https://forem.com/joshmsn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3776962%2F15087b3c-4168-4804-a603-dc4d973be7df.jpg</url>
      <title>Forem: Joshua</title>
      <link>https://forem.com/joshmsn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/joshmsn"/>
    <language>en</language>
    <item>
      <title>I Built a WordPress Plugin That Calculates Snow Loads Per DIN EN 1991-1-3 – Using OpenRouteService for Elevation Data</title>
      <dc:creator>Joshua</dc:creator>
      <pubDate>Tue, 10 Mar 2026 14:00:00 +0000</pubDate>
      <link>https://forem.com/joshmsn/i-built-a-wordpress-plugin-that-calculates-snow-loads-per-din-en-1991-1-3-using-openrouteservice-4071</link>
      <guid>https://forem.com/joshmsn/i-built-a-wordpress-plugin-that-calculates-snow-loads-per-din-en-1991-1-3-using-openrouteservice-4071</guid>
      <description>&lt;p&gt;A WordPress plugin that calculates the structural snow load for any address in Germany – using geocoding, elevation data, and the actual DIN EN 1991-1-3 formulas – and then shows how many days per year a terrace canopy would extend outdoor living. Two tools in one plugin, one address input, two parallel AJAX calls.&lt;/p&gt;

&lt;p&gt;I built this for a company that sells terrace canopies, conservatories, and carports. Their sales team needed two things for every customer consultation: what's the snow load at this location (for structural calculations), and how many extra days of terrace use does a canopy provide (for the sales pitch). Before, they used a buggy Excel spreadsheet for the snow load and guessed the terrace days.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Snow Load Calculation Works
&lt;/h2&gt;

&lt;p&gt;Germany is divided into five snow load zones (1, 1a, 2, 2a, 3). Each zone has its own formula from DIN EN 1991-1-3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Zone 1:  sk = 0.19 + 0.91 × ((A + 140) / 760)²
Zone 1a: sk = 1.25 × (0.19 + 0.91 × ((A + 140) / 760)²)
Zone 2:  sk = 0.25 + 1.91 × ((A + 140) / 760)²
Zone 2a: sk = 1.25 × (0.25 + 1.91 × ((A + 140) / 760)²)
Zone 3:  sk = 0.31 + 2.91 × ((A + 140) / 760)²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;A&lt;/code&gt; = elevation above sea level in meters and &lt;code&gt;sk&lt;/code&gt; = snow load in kN/m².&lt;/p&gt;

&lt;p&gt;The plugin needs two inputs: the snow load zone and the elevation. Getting both from just an address requires three steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Postal Code → Snow Load Zone
&lt;/h2&gt;

&lt;p&gt;A 1.3 MB JSON file maps every German postal code to its snow load zone. This runs as a local lookup – no API call needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$zones&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'snowloadzones.json'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$zones&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$plz&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most reliable part. The postal code mapping is static data from the DIN standard. No external dependency, no rate limits, instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Address → Coordinates → Elevation
&lt;/h2&gt;

&lt;p&gt;This is where OpenRouteService comes in. Two API calls, running server-side via PHP:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geocoding:&lt;/strong&gt; The structured address (street, number, postal code, city) is sent to &lt;code&gt;/geocode/search/structured&lt;/code&gt;. Returns latitude and longitude.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Elevation:&lt;/strong&gt; The coordinates are sent to &lt;code&gt;/elevation/point&lt;/code&gt;. Returns the height above sea level in meters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified flow&lt;/span&gt;
&lt;span class="nv"&gt;$coords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;openroute_geocode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$street&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$city&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// → ['lat' =&amp;gt; 53.5511, 'lng' =&amp;gt; 9.9937]&lt;/span&gt;

&lt;span class="nv"&gt;$elevation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;openroute_elevation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$coords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'lat'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$coords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'lng'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// → 18 (meters above sea level)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose OpenRouteService over Google Maps because it's free for moderate query volumes and doesn't require a credit card. The elevation data comes from SRTM (Shuttle Radar Topography Mission) – accurate enough for snow load calculations where a few meters don't change the result significantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Apply the Formula
&lt;/h2&gt;

&lt;p&gt;Zone + elevation → formula → result.&lt;/p&gt;

&lt;p&gt;For an address in Hamburg (zone 2, 18m elevation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sk = 0.25 + 1.91 × ((18 + 140) / 760)²
sk = 0.25 + 1.91 × (158 / 760)²
sk = 0.25 + 1.91 × 0.0432
sk = 0.33 kN/m²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 34 kg per square meter. The plugin displays both kN/m² and kg/m², plus the full formula so the user can verify the calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Terrace Time Calculator
&lt;/h2&gt;

&lt;p&gt;Same address input, completely different calculation. While the snow load AJAX call runs, a parallel request goes to a weather data API that returns historical temperature statistics for the location.&lt;/p&gt;

&lt;p&gt;The plugin sorts all 365 days into five categories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Temperature&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ice days&lt;/td&gt;
&lt;td&gt;below 0°C&lt;/td&gt;
&lt;td&gt;Not usable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold days&lt;/td&gt;
&lt;td&gt;0–10°C&lt;/td&gt;
&lt;td&gt;Usable with conservatory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Potential days&lt;/td&gt;
&lt;td&gt;10–25°C&lt;/td&gt;
&lt;td&gt;Usable with terrace canopy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summer days&lt;/td&gt;
&lt;td&gt;25–30°C&lt;/td&gt;
&lt;td&gt;Pleasant without any cover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hot days&lt;/td&gt;
&lt;td&gt;above 30°C&lt;/td&gt;
&lt;td&gt;Too hot – canopy shade helps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without a canopy, only summer days count as real terrace days. For Hamburg, that's about 29 days per year (8%).&lt;/p&gt;

&lt;p&gt;The magic happens with two interactive checkboxes: "Terrace canopy" and "Conservatory". When the user checks "Terrace canopy", the potential days are added – the pie chart animates from gray to green, and the number jumps from 29 to around 229 days. Check both, and you're at 344 days (94%).&lt;/p&gt;

&lt;p&gt;The pie chart is rendered with Chart.js. The animation on checkbox click is the key UX moment – watching the chart turn green while the number climbs from 29 to 344 is more persuasive than any brochure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Two Tools in One Plugin?
&lt;/h2&gt;

&lt;p&gt;Because they belong together in practice. Every customer consultation needs both: the snow load for structural engineering and the terrace time visualization for the purchase decision.&lt;/p&gt;

&lt;p&gt;The sales rep enters the customer's address once. Both AJAX requests fire in parallel. Seconds later, both results are on screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Left side:&lt;/strong&gt; Snow load, structural calculations, rafter sizing for different profiles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right side:&lt;/strong&gt; Pie chart showing terrace potential, animated checkboxes for products&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The snow load determines the technical requirements: which rafter profile, what glass thickness, how much reinforcement. The terrace time calculator delivers the sales argument: "At your location, a terrace canopy gives you 200 extra days of outdoor living per year."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Both requests fire in parallel&lt;/span&gt;
&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ajax&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ajaxurl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;calc_snowload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ajax&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ajaxurl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;calculate_regentage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snowResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;weatherResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderSnowLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snowResult&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;renderTerraceTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weatherResult&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Expert Mode
&lt;/h2&gt;

&lt;p&gt;There are actually two modes. The &lt;strong&gt;standard mode&lt;/strong&gt; is public on the company website – anyone can enter an address, get their snow load and terrace potential, and leave their email for the full report. Classic lead magnet.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;expert mode&lt;/strong&gt; is for the sales team only. It adds input fields for the planned canopy dimensions: width, depth, glass thickness, height offset, rafter profile. The plugin then calculates not just the snow load but the actual load on the planned structure – including snow load with and without wedge factor, total weight in kg, and rafter sizing across all available profiles (S through XL+).&lt;/p&gt;

&lt;p&gt;It checks deflection and stress limits for the company's own product lines and shows green checkmarks for profiles that pass. The sales rep can configure the exact canopy in front of the customer and show immediately whether the chosen profile handles the local snow load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Address Input (Street, Nr, Postal Code, City)
              │
      ┌───────┴───────┐
      ▼               ▼
  SNOW LOAD      TERRACE TIME
      │               │
  PLZ → JSON      Weather API
  (Zone 1-3)    (Temperature data)
      │               │
  Geocoding           │
  (→ Coordinates)     │
      │               │
  Elevation           │
  (→ Height ASL)      │
      │               │
  DIN Formula      Sort days
  (Zone + Height)  (5 categories)
      │               │
      ▼               ▼
  0.33 kN/m²     29 → 344 days
  (34 kg/m²)     (+315 with canopy)
      │               │
      └───────┬───────┘
              ▼
      Result Display
  (Snow load + Pie chart)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin is ~12 files total. PHP backend, jQuery frontend, two JSON data files, email and print templates. No build step, no npm, no framework. It's a WordPress plugin – it gets uploaded, activated, and works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Caching.&lt;/strong&gt; Every calculation makes two live API calls to OpenRouteService. For a tool used by a sales team with maybe 20 queries per day, that's fine. But if the public version gets heavy traffic, I'd add transient caching keyed by postal code + street. Most addresses in the same postal code area have nearly identical elevations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The weather data API&lt;/strong&gt; is a separate plugin that I didn't build. If I were starting fresh, I'd integrate it into the same plugin and use Open-Meteo's free historical weather API. One less dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript instead of jQuery.&lt;/strong&gt; The plugin was built for a specific WordPress setup where jQuery was already loaded. For a new project, I'd use vanilla JS or a small reactive framework for the interactive elements.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Joshua, freelance web developer from Hamburg. I build WordPress plugins, Shopify themes and custom web tools. More case studies on &lt;a href="https://hafenpixel.de/hinter-den-kulissen" rel="noopener noreferrer"&gt;hafenpixel.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a SaaS Licensing System With WooCommerce – No Custom Tables, No License Keys</title>
      <dc:creator>Joshua</dc:creator>
      <pubDate>Tue, 03 Mar 2026 14:00:00 +0000</pubDate>
      <link>https://forem.com/joshmsn/building-a-saas-licensing-system-with-woocommerce-no-custom-tables-no-license-keys-21ik</link>
      <guid>https://forem.com/joshmsn/building-a-saas-licensing-system-with-woocommerce-no-custom-tables-no-license-keys-21ik</guid>
      <description>&lt;p&gt;You can run a SaaS product on WooCommerce. Not a huge one – but for a niche tool with a few hundred users, it works surprisingly well. No custom database tables, no license key management, no Stripe integration. Just WooCommerce Subscriptions, a custom plugin, and WordPress User Meta.&lt;/p&gt;

&lt;p&gt;I built this for a client who runs a price calculation tool for hair salons in Germany. The tool helps salon owners calculate their service prices based on employee count, material costs, and time. Over 100 salons pay annual subscriptions to use it. The entire licensing, payment, and access control system runs on WooCommerce.&lt;/p&gt;

&lt;p&gt;Here's how it works technically – and why I chose this approach over a traditional SaaS stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;The client had a working tool but needed a way to sell access to it. The requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Annual subscriptions with automatic renewal&lt;/li&gt;
&lt;li&gt;6 license tiers based on salon size (0, 2, 5, 10, 15, 30 employees)&lt;/li&gt;
&lt;li&gt;Self-service purchase and renewal&lt;/li&gt;
&lt;li&gt;The client (non-technical) should be able to manage everything himself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The obvious choice would have been a custom SaaS stack: Stripe Billing for payments, a custom backend for license management, a separate admin panel. That would have cost €15,000–20,000 and taken 8–12 weeks.&lt;/p&gt;

&lt;p&gt;Instead, I built it on the existing WordPress installation where the tool already lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Key Insight: User Meta Instead of License Keys
&lt;/h2&gt;

&lt;p&gt;Traditional license systems generate keys, store them in a database, validate them on every request. Keys can leak, need rotation, and require a custom management UI.&lt;/p&gt;

&lt;p&gt;I skipped all of that. The license is tied to the WordPress user account. When someone buys a subscription, four values are written to &lt;code&gt;wp_usermeta&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// That's the entire "license"&lt;/span&gt;
&lt;span class="nf"&gt;update_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sve_license_active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'yes'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;update_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sve_license_expiry'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$expiry_timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;update_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sve_license_level'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$level&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;update_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sve_license_sku'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product_sku&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No custom tables. No license keys. No keys that can leak or be shared. The license is bound to the WordPress account – the user logs in and has access. That's it.&lt;/p&gt;

&lt;p&gt;This works because the tool runs as a WordPress plugin on the same installation. It's not an external SaaS that needs API authentication. It's a page on the same site, protected by standard WordPress login.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access Control
&lt;/h2&gt;

&lt;p&gt;A single function checks access and returns one of three states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;check_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sve_license_active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$expiry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sve_license_expiry'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$active&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'yes'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expiry&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'readonly'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'full'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three states, not two. This is the part I'm most happy with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;full&lt;/code&gt;&lt;/strong&gt;: Active license – create new calculations, full access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;readonly&lt;/code&gt;&lt;/strong&gt;: License expired – can still view all old data, but can't create new calculations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;none&lt;/code&gt;&lt;/strong&gt;: No license at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;readonly&lt;/code&gt; state is deliberate. When a subscription expires, the user doesn't lose access to their data. They can still log in, see all their calculations, export their data. They just can't create new ones. That's a much better experience than a hard lockout – and it's a strong incentive to renew, because they see exactly what they're missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  WooCommerce Does the Hard Part
&lt;/h2&gt;

&lt;p&gt;Payment processing, subscription management, renewal reminders, failed payment handling – WooCommerce Subscriptions handles all of it. I just hook into the lifecycle events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Activate on purchase&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_order_status_completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'activate_license'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Extend on renewal&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_subscription_renewal_payment_complete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'extend_license'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Deactivate on cancellation&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_order_status_cancelled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deactivate_license'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'woocommerce_order_status_refunded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deactivate_license'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;activate_license&lt;/code&gt; function reads the product SKU to determine the license level, calculates the expiry date, and writes the four User Meta values. That's ~30 lines of PHP for the entire activation logic.&lt;/p&gt;

&lt;p&gt;PayPal Commerce Platform handles payments. The activation is gateway-agnostic – it triggers on &lt;code&gt;order_status_completed&lt;/code&gt;, regardless of whether the customer paid via PayPal, credit card, or bank transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Client Can Manage Himself
&lt;/h2&gt;

&lt;p&gt;This was a hard requirement: the client is not technical. He needed to manage everything through the WordPress backend without calling me. He can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create and modify license tiers (just WooCommerce products)&lt;/li&gt;
&lt;li&gt;Edit all text content on the tool pages&lt;/li&gt;
&lt;li&gt;Configure email intervals and content for expiry reminders&lt;/li&gt;
&lt;li&gt;Create coupons and upgrade discounts&lt;/li&gt;
&lt;li&gt;View all active licenses (it's just a user list with meta fields)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No custom admin panel needed. The WordPress backend is the admin panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;WooCommerce Approach&lt;/th&gt;
&lt;th&gt;Traditional SaaS Stack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Development time&lt;/td&gt;
&lt;td&gt;~4 weeks part-time&lt;/td&gt;
&lt;td&gt;8–12 weeks (estimate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development cost&lt;/td&gt;
&lt;td&gt;Low four figures&lt;/td&gt;
&lt;td&gt;€15,000–20,000 (estimate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting/month&lt;/td&gt;
&lt;td&gt;Standard shared hosting&lt;/td&gt;
&lt;td&gt;€50–200/month (cloud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment processing&lt;/td&gt;
&lt;td&gt;WooCommerce + PayPal&lt;/td&gt;
&lt;td&gt;Stripe Billing integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin interface&lt;/td&gt;
&lt;td&gt;WordPress backend&lt;/td&gt;
&lt;td&gt;Custom-built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License management&lt;/td&gt;
&lt;td&gt;User Meta (4 fields)&lt;/td&gt;
&lt;td&gt;Custom database + API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The WooCommerce approach was faster, cheaper, and gave the client full control from day one. The tradeoff: it doesn't scale to thousands of concurrent users. But for a niche B2B tool with 100+ licenses, it doesn't need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Approach Works – and When It Doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WooCommerce as SaaS backend makes sense when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your tool already runs on WordPress (or can run as a plugin)&lt;/li&gt;
&lt;li&gt;You have hundreds, not thousands of users&lt;/li&gt;
&lt;li&gt;Annual or monthly subscriptions with simple tiers&lt;/li&gt;
&lt;li&gt;The operator wants self-service management&lt;/li&gt;
&lt;li&gt;Budget is limited and time-to-market matters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use a proper SaaS stack when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need real-time features or websockets&lt;/li&gt;
&lt;li&gt;Thousands of concurrent users are expected&lt;/li&gt;
&lt;li&gt;You need fine-grained API rate limiting&lt;/li&gt;
&lt;li&gt;Multi-tenancy with data isolation is required&lt;/li&gt;
&lt;li&gt;The product will eventually need its own mobile app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key question is: does your use case actually need the complexity of a custom stack? For a niche B2B tool with a few hundred users, the answer is often no. WooCommerce gives you payments, subscriptions, user management, and an admin panel out of the box. Adding license control on top is a few hundred lines of PHP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anonymous Analytics Bonus
&lt;/h2&gt;

&lt;p&gt;One unexpected benefit: since all calculations run through the WordPress plugin, I was able to add anonymous aggregated analytics. The client can see average calculation values, pricing patterns, and employee planning trends across all salons – without seeing individual data.&lt;/p&gt;

&lt;p&gt;This gives him benchmark data for product improvement and a potential value-add for users: "See how your prices compare to the average salon in your region."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Joshua, freelance web developer from Hamburg. I build WordPress plugins, Shopify themes and custom web tools. More case studies on &lt;a href="https://hafenpixel.de/hinter-den-kulissen" rel="noopener noreferrer"&gt;hafenpixel.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Built 3 Shopify Cart Features Without a Single App – Just Liquid &amp; JS</title>
      <dc:creator>Joshua</dc:creator>
      <pubDate>Tue, 24 Feb 2026 14:00:00 +0000</pubDate>
      <link>https://forem.com/joshmsn/i-built-3-shopify-cart-features-without-a-single-app-just-liquid-js-27k9</link>
      <guid>https://forem.com/joshmsn/i-built-3-shopify-cart-features-without-a-single-app-just-liquid-js-27k9</guid>
      <description>&lt;p&gt;Three Shopify features that usually require three separate apps – a progress bar, an auto-add free gift, and cart recommendations – can be built with custom Liquid and JavaScript in a single solution. No monthly fees, no extra HTTP requests, no performance hit.&lt;/p&gt;

&lt;p&gt;I'm a freelance developer from Hamburg, and I recently did exactly this for a Shopify store called Bodenständig. They sell garden supplies for home growers – seeds, fertilizer, edible plants. The store was running Monk for the progress bar and gift logic. The cart recommendations didn't exist before – I built all three features from scratch as a single custom solution, replacing the app in the process. It worked, but it loaded its own script bundle, injected its own CSS, and made extra API calls on every page load. On mobile – where most of their customers shop – it was noticeably slow.&lt;/p&gt;

&lt;p&gt;Here's how I replaced it all with code that lives directly in the Dawn theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Shopify Apps
&lt;/h2&gt;

&lt;p&gt;Every Shopify app loads its own JavaScript, its own CSS, and often makes additional external requests – on every single page view. Three apps for cart features can add hundreds of milliseconds to your page load.&lt;/p&gt;

&lt;p&gt;Apps run as an external layer on top of your theme. They have to be generic because they need to work across thousands of different stores. That makes them inherently heavier and less optimized than a custom solution.&lt;/p&gt;

&lt;p&gt;And they charge monthly. In this case, about €50/month. That's €600/year. Over three years, €1,800. For code that doesn't belong to the store owner and disappears the moment you unsubscribe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 1: Progress Bar
&lt;/h2&gt;

&lt;p&gt;The progress bar shows customers in real-time how close they are to two thresholds: free seed packet at €30, free shipping at €60.&lt;/p&gt;

&lt;p&gt;The initial state is calculated server-side in Liquid, so the bar renders correctly the instant the cart drawer opens – no flicker, no loading state. JavaScript takes over for subsequent updates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;{% raw %}
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;cart_total_without_gift&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;is_gift_item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;prop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;properties&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'_gift'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ow"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
      &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;is_gift_item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endif&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endfor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;unless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;is_gift_item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;cart_total_without_gift&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;cart_total_without_gift&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;final_line_price&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
  &lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endunless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endfor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
&lt;span class="cp"&gt;{%-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;assign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;progress_pct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;cart_total_without_gift&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;divided_by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;-%}&lt;/span&gt;
{% endraw %}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important detail: the progress bar calculates the cart value &lt;em&gt;without&lt;/em&gt; the gift product. Otherwise, adding the free item would affect the bar – a common bug in app-based solutions.&lt;/p&gt;

&lt;p&gt;The text updates dynamically based on the current total:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Under €30: "Noch €X für ein gratis Saatgut-Tütchen" &lt;em&gt;(€X until a free seed packet)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;€30–€60: "🎁 Gratis Saatgut gesichert! Noch €X bis gratis Versand" &lt;em&gt;(Free seeds secured! €X until free shipping)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;€60+: "✅ Gratis Versand &amp;amp; gratis Saatgut gesichert!" &lt;em&gt;(Free shipping &amp;amp; free seeds secured!)&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bar animates smoothly via a CSS transition on the width property. Two markers at the 30€ and 60€ positions turn green when reached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 2: Auto-Add/Remove Free Gift
&lt;/h2&gt;

&lt;p&gt;When the cart value hits €30, a free seed packet is automatically added. When it drops below €30, it's automatically removed. The customer doesn't have to do anything.&lt;/p&gt;

&lt;p&gt;The gift is a regular Shopify product priced at €0 with status "unlisted" – it doesn't appear in the store but is accessible via its variant ID through the API. When adding it, a hidden property &lt;code&gt;_gift: true&lt;/code&gt; is attached. The underscore prefix is key: Shopify hides properties starting with &lt;code&gt;_&lt;/code&gt; from the checkout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/cart/add.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GIFT_VARIANT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_gift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;code&gt;_gift&lt;/code&gt; property identifies the gift item everywhere – when calculating the cart total without the gift, when rendering it differently in the cart (green background, "🎁 Geschenk" badge instead of a price), and when removing it.&lt;/p&gt;

&lt;p&gt;The gift item looks different from regular products: green background, no price (shows "Kostenlos" instead), no quantity selector, no remove button – just a 🎁 emoji. The customer immediately sees: this is a gift, it belongs there, they can't modify it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hardest part was avoiding race conditions.&lt;/strong&gt; When a customer rapidly adds or removes products, AJAX calls can overlap. The solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;code&gt;isUpdating&lt;/code&gt; flag that blocks concurrent operations&lt;/li&gt;
&lt;li&gt;Rate limiting: minimum 3 seconds between gift checks&lt;/li&gt;
&lt;li&gt;A debounce timer on cart events (800ms)&lt;/li&gt;
&lt;li&gt;A delay after cart operations before checking gift eligibility (1,500ms)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;isUpdating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastGiftCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkGiftEligibility&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isUpdating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeSinceLastCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastGiftCheck&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeSinceLastCheck&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;lastGiftCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/cart.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalWithoutGift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCartTotalWithoutGift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasGift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nf"&gt;findGiftItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalWithoutGift&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;GIFT_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasGift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addGift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalWithoutGift&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;GIFT_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hasGift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;removeGift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without these safeguards, rapid clicking could add the gift twice or remove it during a brief intermediate state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 3: Cart Recommendations
&lt;/h2&gt;

&lt;p&gt;The cart drawer shows product recommendations below the cart items – powered by Shopify's own Product Recommendations API, not an external app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/recommendations/products?section_id=cart-recommendations&amp;amp;product_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;limit=10&amp;amp;intent=complementary`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recommendations are rendered as a separate Shopify Section (&lt;code&gt;cart-recommendations&lt;/code&gt;). This means they use Shopify's template engine and don't need an external API. No extra JavaScript bundle, no monthly fee, no third-party requests.&lt;/p&gt;

&lt;p&gt;The system iterates through all products in the cart and collects recommendations with the &lt;code&gt;complementary&lt;/code&gt; intent from Shopify's Search &amp;amp; Discovery algorithm. Products already in the cart are filtered out.&lt;/p&gt;

&lt;p&gt;When a customer clicks "hinzufügen" (add), the product is added via AJAX with a hidden property &lt;code&gt;_source: cart_recommendation&lt;/code&gt; – this lets the store owner track in their orders which products came from recommendations.&lt;/p&gt;

&lt;p&gt;A MutationObserver on the cart items container detects DOM changes by the Dawn theme and triggers a recommendation reload. Parallel requests are prevented with a loading flag that has a safety reset after 10 seconds in case something gets stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  How All Three Features Work Together
&lt;/h2&gt;

&lt;p&gt;This is the key advantage over three separate apps: all features share a single JavaScript block and react to the same cart events.&lt;/p&gt;

&lt;p&gt;The flow on any cart change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Customer adds a product or changes quantity&lt;/li&gt;
&lt;li&gt;Dawn fires a &lt;code&gt;cart:updated&lt;/code&gt; event&lt;/li&gt;
&lt;li&gt;A central handler waits 800ms (debounce), then fetches the current cart via &lt;code&gt;/cart.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Progress bar updates with the new cart value&lt;/li&gt;
&lt;li&gt;After another 1,500ms, the gift check runs&lt;/li&gt;
&lt;li&gt;Recommendations reload in parallel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The debounce and staggered timeouts matter. If a customer clicks "plus" three times quickly, you don't want three cart fetches, three gift checks, and three recommendation loads. The system waits until the click sequence is done, then does one clean update pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cartChangeTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleCartChange&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartChangeTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cartChangeTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cartChangeTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateFromCart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;checkGiftEligibility&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cart:updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleCartChange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cart:change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleCartChange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;App Solution&lt;/th&gt;
&lt;th&gt;Custom Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost&lt;/td&gt;
&lt;td&gt;~€50/month&lt;/td&gt;
&lt;td&gt;€0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance impact&lt;/td&gt;
&lt;td&gt;Extra scripts &amp;amp; requests&lt;/td&gt;
&lt;td&gt;Zero (theme-integrated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customizability&lt;/td&gt;
&lt;td&gt;Limited to app settings&lt;/td&gt;
&lt;td&gt;Full control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code ownership&lt;/td&gt;
&lt;td&gt;Rental (gone if you cancel)&lt;/td&gt;
&lt;td&gt;Store owner's property&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development time&lt;/td&gt;
&lt;td&gt;Instant setup&lt;/td&gt;
&lt;td&gt;A few days, one-time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3-year cost&lt;/td&gt;
&lt;td&gt;~€1,800&lt;/td&gt;
&lt;td&gt;One-time dev cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When Custom Code Beats Apps
&lt;/h2&gt;

&lt;p&gt;Custom code works when the requirement is well-defined, the feature can be solved with Liquid and JavaScript, and the store is a long-term operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom code wins when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The function is clearly scoped (progress bars, gift logic, cart recommendations are perfect candidates)&lt;/li&gt;
&lt;li&gt;The app causes performance issues&lt;/li&gt;
&lt;li&gt;You need a custom look that app settings can't achieve&lt;/li&gt;
&lt;li&gt;Monthly app costs exceed the one-time development cost within 6–12 months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;An app wins when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The feature is complex and needs regular updates (subscription management with dunning, complex bundle pricing)&lt;/li&gt;
&lt;li&gt;The store owner wants to change settings frequently without a developer&lt;/li&gt;
&lt;li&gt;The app brings A/B testing or analytics that would be expensive to rebuild&lt;/li&gt;
&lt;li&gt;The store is brand new and it's unclear which features will stick&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rule of thumb:&lt;/strong&gt; if an app essentially injects CSS and JavaScript to change a frontend display, it can almost always be built better and cheaper with custom code. If an app brings its own backend with a database and complex business logic, custom code usually doesn't make sense.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Joshua, freelance web developer from Hamburg. I build WordPress plugins, Shopify themes and custom web tools. If you want to see the live implementation, check out &lt;a href="https://bodenstaendig.shop" rel="noopener noreferrer"&gt;Bodenständig's shop&lt;/a&gt;. More case studies on &lt;a href="https://hafenpixel.de/hinter-den-kulissen" rel="noopener noreferrer"&gt;hafenpixel.de&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
