Safer Lighting
Sister company to Safer Electric · ESA Licence #7008996 · Real GTA warehouse · Trade contractor accounts
Safer Lighting wholesales LED commercial fixtures, electrical boxes, PVC conduit, and trade hardware out of a real GTA warehouse. They wanted a storefront that worked for a regular homeowner buying a couple of fixtures online, without alienating the professional contractors who needed wholesale pricing, quantity breaks, and account-tagged discounts. The catch was that the established players in this category all hide their prices behind a 24-to-72 hour login wall, which is a terrible experience if you've got a bid due in four hours.
What we built isn't a Dawn install with a fresh logo. It's a real B2B catalog with two-tier pricing that flips automatically when a contractor logs in, manual approval gating for trade accounts, customer accounts kept on the canonical domain so the URL never bounces off to shopify.com, and a Python pipeline that takes QuickBooks data and produces the Shopify import CSV the merchant uploads.
Custom Shopify 2.0 on a Dawn fork
We took Dawn for the accessibility floor and the 2.0 sectioning model, then rebuilt every surface in our own design system. ~68 sections, ~50 snippets, all custom or rewritten. JSON section templates so every page is editable from the admin without code. Vanilla JS only. No jQuery. No extra frameworks. CSP-friendly.
Two-tier B2B pricing, the hard part
Retail sees cost × 2.24. Approved contractors see cost × 2.16, about 3% lower at the same SKU, applied automatically when the logged-in customer carries the trade tag. Display compare-at is sale ÷ 0.775, so every product anchors at ~22.5% off. Quantity breaks: −8% at 5 units, −15% at 25, −22% at 100. snippets/price.liquid reads a pricing.b2b_price metafield and swaps the active price and strikethrough on the fly.
Manual contractor approval
Application form at /pages/contractor-application collects company name, business email, and ESA licence #. Submissions email the admin, who verifies the licence number against Ontario's public ESA directory and then tags the customer approved-contractor from Shopify admin. Contractors can't self-grant trade pricing.
Transparency as the moat
Arani, the dominant player in this category, hides every price behind a 24-72 hour B2B login wall. Public Shopify pricing wins the contractor whose bid is due in four hours. That's a structural moat, not just a feature. We still protect trade pricing for approved accounts. We just don't gatekeep the retail catalog.
Custom semantic search
<safer-search> is a custom element in assets/safer-search.js. Predictive search with 50+ synonym groups that map shopper terms to technical product types. A homeowner typing "pot light" finds the recessed downlight page. A contractor typing "high bay" finds the UFO highbay. "Wall pack" resolves to outdoor wallpack fixtures. Mobile uses a persistent horizontal search bar pinned under the header instead of Dawn's modal.
Python + Node data pipeline
scripts/build-products.mjs builds the canonical catalog. import-products.cjs pushes to Shopify's product import API. create-collections, create-pages, create-redirects, and create-metafield-definitions are idempotent admin scripts. Pricing flows QuickBooks export → cost-keyed pricing strategy CSV → Shopify import CSV. One Python script. Deterministic. Re-runnable any time prices change.
The receipts. Three concrete bug-fix-and-redesign moments. Anyone can stand up a Shopify theme. Diagnosing layered failures and fixing them at root cause is the work.
Two pricing bugs stacked on top of each other
The storefront was rendering compare-at-price equal to sale price, so every product looked struck through with the same number. Two stacked failures. A CSS override was forcing strikethrough on every .price-item--regular regardless of sale state, and the import data had blank Compare-at on every row. Diagnosed via live HTML inspection. Fixed at root: CSS gated to .price--on-sale, data regenerated from the pricing CSV with proper compare-at math.
Collection grid orphan card
10 products in a 4-column grid left an orphan row of 2, and Dawn's flex grid stretched them to 100% via flex-grow: 1. Fixed by forcing display: grid at every breakpoint with explicit grid-template-columns: repeat(N, 1fr), so orphans always sit at 1/N of the row.
Mobile drawer overlap with :has() and a JS fallback
The bottom phone-CTA, signup row, and Quick Order FAB all bled through the Shop submenu when it slid in. Root cause: Dawn's stock CSS hides the outer .menu-drawer__menu via .submenu-open with visibility:hidden, but the CTAs are siblings of the menu, not children. Fix: :has(.submenu-open) selectors on the wrapper containers, plus a JS-class-based fallback (body.overflow-hidden-tablet) for browsers that don't yet support :has().
// Same DNA as Safer Electric and Safer Inventory
The Safer Brand Design System lives at assets/brand-typography.css and assets/silicon-valley.css. One system, two production properties, three apps in the inventory monorepo. All of them consuming the same tokens.
Navy #0c1e33. Gold #d6b059 used sparingly, never as fill. Cream #f0ede6 over navy, never pure white. Cormorant Garamond for headlines (italic for emphasis). Plus Jakarta Sans for body and UI. JetBrains Mono for SKUs, prices, and licence numbers. A ThreeJS hero canvas reads document.documentElement.dataset.theme and swaps particle colors live when the user toggles dark/light.
// The right tool for the job
Liquid + vanilla JS, by design. We could have shipped a headless React storefront. We chose not to. Shopify's sectioning model gives the merchant control without code, the SSR is bulletproof, and the build deploys via Shopify's GitHub integration in ~30 seconds. Right tool, not newest tool.