# 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. ```sh 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 ```sh 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..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).