Icon rebuilt as a full-bleed 1200x1200 square (Shopify rounds corners itself). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
7.3 KiB
Markdown
116 lines
7.3 KiB
Markdown
# 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.<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).
|