metatroncubeswdev b59c2d0668 Plans page: show yearly prices and Plus extras
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:58:22 -04:00

Offerlyst: Bundles & BOGO (Shopify app)

Brand assets: brand/ (App Store icon offerlyst-icon-1200.png).

Public Shopify app that re-creates the ClicksToCart Odoo module c2c_bundle_offers: fixed bundles, mix & match and buy X get Y, applied by one automatic app discount whose function evaluates every running offer in priority order, so each cart item counts towards at most one offer. The spec is ../shopify-bundle-app-brief/index.html.

Layout

Path What
extensions/bundle-discount/ Discount Function in Rust (cart.lines.discounts.generate.run, API 2026-07). src/engine.rs = bundle engine.
extensions/bundle-offers-theme/ Theme app extension: app embed + blocks (bundles grid, homepage strip, "Save with a bundle", cart hints).
engine/engine.js JS engine: used by the admin "example cart" preview and as the reference the Rust port is tested against.
app/lib/compile.ts Offers → function config ($app:configuration), input variables ($app:function-vars), storefront data. Plan limits enforced here.
app/lib/publish.server.ts Creates the shop's single discount and writes the metafields.
app/lib/scheduler.server.ts Republishes at every offer start/end (30 s tick, multi-instance safe).
app/routes/app.* Admin (Polaris web components): home/setup, offers, editor, templates, analytics, settings, plans.
app/routes/proxy.* App proxy pages /apps/bundles and /apps/bundles/{handle} (guests OK, 404 when not running, 301 for old handles).
app/routes/webhooks.* uninstall, plan changes, orders/create (analytics), catalog changes, GDPR.
spec/ Config schema (extended: ba, g, cc) + the 19 parity vectors.

Run locally

Requirements: Node 22.12+, Rust (stable, target wasm32-unknown-unknown), Shopify CLI, Postgres.

docker run -d --name bundle-offers-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=bundle_offers -p 54329:5432 postgres:17-alpine
echo DATABASE_URL="postgresql://postgres:postgres@localhost:54329/bundle_offers?schema=public" > .env
npm install
npx prisma migrate deploy
npm run config:link      # once: connects this folder to the app in your Partner Dashboard
npm run dev              # tunnels, installs on your dev store

On Windows, use the GNU Rust toolchain if Visual Studio's C++ tools aren't installed: rustup default stable-x86_64-pc-windows-gnu.

Tests

npm test   # JS engine + compiler + storefront Liquid (vitest) and the Rust function (cargo test)
  • Parity: the Rust engine and the JS engine both reproduce all 19 vectors from the original Odoo engine.
  • Differential: the optimised JS engine equals the reference port on 5,000 random carts.
  • Function: fixed/mix/bxgy, priority + exclusivity, max uses, currency conversion, exclusions, country targeting, the schedule guard, merged lines, get-units-only allocation.

Instruction budget (Shopify limit: 11M for ≤ 200 lines), measured with shopify app function run

Cart Rust
10 lines, 10 offers 0.76M (7%)
50 lines × 2, 20 offers 3.1M (28%)
100 lines × 2, 20 offers, 5 collections 5.8M (53%)
200 lines × 5, 50 offers, 10 collections 18.5M (over)

The JS version needed 39M just to read a 200-line cart, so the function is Rust (brief §11.1 allows this). Most of the remaining cost is the size of the input Shopify sends (collection memberships), not the engine. If a cart exceeds the budget, Shopify skips the discount for that cart; checkout still works.

Deploy & launch runbook

  1. Partner Dashboard: create the app "Bundle Offers"; npm run config:link.
  2. Hosting: any Node host with Postgres (Fly.io, Render, Railway, Heroku). Env: SHOPIFY_API_KEY, SHOPIFY_API_SECRET, SHOPIFY_APP_URL, SCOPES, DATABASE_URL, SHOPIFY_APP_HANDLE. Run npm run setup && npm run start (the Dockerfile does this). Run one long-lived process at least (the scheduler lives in the web process; several instances are safe).
  3. npm run deploy: pushes the function, theme extension, webhooks and app proxy config.
  4. Managed pricing (Partner Dashboard › Pricing): plans named Starter, Growth, Plus (the names map to limits in app/lib/plans.ts), 14-day trial.
  5. Dev store test (see "Verify on a dev store"), Dawn + one paid theme, desktop + mobile.
  6. App Store listing: name, subtitle, screenshots, 30-s video, privacy policy URL, support email; submit.

App Store review usually takes several days or more. Until it's approved, the app can run on development stores. To use it on the ClicksToCart store sooner, create a second app with custom distribution from the same code (separate shopify.app.<name>.toml).

Verify on a dev store (couldn't be tested without a store)

  • The discount is created (functionHandle: "bundle-discount") and applies fixed/mix/bxgy in cart and checkout.
  • A line whose units belong to two offers gets one merged amount (message "A + B").
  • inCollections input variables are delivered from $app:function-vars.
  • orders/create: the discount application title is the candidate message (offer name). If Shopify reports only "Bundle Offers", analytics group under that title (see app/lib/orders.server.ts).
  • Uninstall removes the automatic discount and app metafields (expected: Shopify does it with the app).
  • App embed toggle detection on Home (reads config/settings_data.json of the main theme).
  • Cart drawer refresh after "Add bundle to cart" with the drawer setting on (Dawn's cart-drawer).

Report against the brief

Deviations (with reasons)

Brief Built Why
§11.1 JS function first Rust JS: 39M instructions to read a 200-line cart (limit 11M).
§11.2 localization.market localization.country + per-offer country list (F08) market is deprecated in the function input; discount context can target markets for the whole discount only.
§11.1 functionId functionHandle functionId is deprecated in DiscountAutomaticAppInput.
§11.5 secondary clock guard shop.localTime.dateTimeAfter($guardAt) The function can't read UTC time; the earliest end is passed as an input variable, so an ending offer never outlives the scheduler.
§11.3 one candidate per (offer, line) one candidate per line, amounts merged Avoids two candidates on the same line from one discount (behaviour not documented).
F20 kanban + drag handle list with image/badge/status; ↑/↓ priority buttons Polaris has no kanban; buttons are keyboard-accessible.
F11 marketplace sellers / promo lines exclusion tag; combinesWith settings Shopify equivalents (brief §10).
§13 "Bundles page created during onboarding" /apps/bundles via app proxy + optional grid block Creating pages needs write_online_store_pages, outside the allowed scopes.
F24 translations admin EN; storefront EN/FR theme strings; per-offer name/badge/description per locale Proxy page labels are English for now.
Analytics "AOV without bundles" counts orders received since install Needs orders/create history.

Not done yet

  • Admin French (FR-CA) translation of the embedded app.
  • In-app review prompt after 5 discounted orders (flag exists on Shop).
  • Festival reminder emails 6 weeks before a festival (calendar is in the app).
  • E2E tests on a dev store (checklist above).
Description
No description provided
Readme 7.3 MiB
Languages
TypeScript 57%
JavaScript 16.7%
Rust 10.9%
Python 5.7%
Liquid 5.7%
Other 4%