Rust discount function (one automatic discount evaluating all offers in priority order, parity with the Odoo engine), React Router admin with Polaris, theme app extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
7.3 KiB
Markdown
114 lines
7.3 KiB
Markdown
# Bundle Offers (Shopify app)
|
||
|
||
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.<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).
|