Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
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>
This commit is contained in:
commit
365e45406a
8
.cursor/mcp.json
Normal file
8
.cursor/mcp.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shopify-dev-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
.cache
|
||||
build
|
||||
node_modules
|
||||
**/node_modules
|
||||
extensions/bundle-discount/target
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
.shopify
|
||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
||||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# Markdown syntax specifies that trailing whitespaces can be meaningful,
|
||||
# so let’s not trim those. e.g. 2 trailing spaces = linebreak (<br />)
|
||||
# See https://daringfireball.net/projects/markdown/syntax#p
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# Copy to .env on the server and fill in. Never commit .env.
|
||||
APP_DOMAIN=bundles.example.com
|
||||
SHOPIFY_API_KEY=your_client_id
|
||||
SHOPIFY_API_SECRET=your_client_secret
|
||||
SHOPIFY_APP_HANDLE=bundle-offers
|
||||
POSTGRES_PASSWORD=change-me-to-a-long-random-string
|
||||
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
build
|
||||
public/build
|
||||
*/*.yml
|
||||
.shopify
|
||||
96
.eslintrc.cjs
Normal file
96
.eslintrc.cjs
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* This is intended to be a basic starting point for linting in your app.
|
||||
* It relies on recommended configs out of the box for simplicity, but you can
|
||||
* and should modify this configuration to best suit your team's needs.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
},
|
||||
ignorePatterns: ["!**/.server", "!**/.client"],
|
||||
|
||||
// Base config
|
||||
extends: ["eslint:recommended"],
|
||||
|
||||
overrides: [
|
||||
// React
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
plugins: ["react", "jsx-a11y"],
|
||||
extends: [
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
formComponents: ["Form"],
|
||||
linkComponents: [
|
||||
{ name: "Link", linkAttribute: "to" },
|
||||
{ name: "NavLink", linkAttribute: "to" },
|
||||
],
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/no-unknown-property": ["error", { ignore: ["variant"] }],
|
||||
},
|
||||
},
|
||||
|
||||
// Typescript
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
settings: {
|
||||
"import/internal-regex": "^~/",
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".ts", ".tsx"],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
],
|
||||
},
|
||||
|
||||
// Node
|
||||
{
|
||||
files: [
|
||||
".eslintrc.cjs",
|
||||
"vite.config.{js,ts}",
|
||||
".graphqlrc.{js,ts}",
|
||||
"shopify.server.{js,ts}",
|
||||
"**/*.server.{js,ts}",
|
||||
],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
globals: {
|
||||
shopify: "readonly"
|
||||
},
|
||||
};
|
||||
10
.gemini/extensions/shopify-dev-mcp/gemini-extension.json
Normal file
10
.gemini/extensions/shopify-dev-mcp/gemini-extension.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "shopify-dev-mcp",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"shopify-dev-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.wasm binary
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
node_modules
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/app/build
|
||||
/public/build/
|
||||
/public/_dev
|
||||
/app/public/build
|
||||
/prisma/dev.sqlite
|
||||
/prisma/dev.sqlite-journal
|
||||
database.sqlite
|
||||
|
||||
.env
|
||||
.env.*
|
||||
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
/extensions/*/dist
|
||||
|
||||
# Ignore shopify files created during app dev
|
||||
.shopify/*
|
||||
.shopify.lock
|
||||
|
||||
# Hide files auto-generated by react router
|
||||
.react-router/
|
||||
|
||||
!.env.example
|
||||
42
.graphqlrc.ts
Normal file
42
.graphqlrc.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import fs from "fs";
|
||||
import { ApiVersion } from "@shopify/shopify-app-react-router/server";
|
||||
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
|
||||
import type { IGraphQLConfig } from "graphql-config";
|
||||
|
||||
function getConfig() {
|
||||
const config: IGraphQLConfig = {
|
||||
projects: {
|
||||
default: shopifyApiProject({
|
||||
apiType: ApiType.Admin,
|
||||
apiVersion: ApiVersion.October26,
|
||||
documents: ["./app/**/*.{js,ts,jsx,tsx}", "./app/.server/**/*.{js,ts,jsx,tsx}"],
|
||||
outputDir: "./app/types",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
let extensions: string[] = [];
|
||||
try {
|
||||
extensions = fs.readdirSync("./extensions");
|
||||
} catch {
|
||||
// ignore if no extensions
|
||||
}
|
||||
|
||||
for (const entry of extensions) {
|
||||
const extensionPath = `./extensions/${entry}`;
|
||||
const schema = `${extensionPath}/schema.graphql`;
|
||||
if (!fs.existsSync(schema)) {
|
||||
continue;
|
||||
}
|
||||
config.projects[entry] = {
|
||||
schema,
|
||||
documents: [`${extensionPath}/**/*.graphql`],
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
export default config;
|
||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shopify-dev-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@shopify/dev-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
||||
package.json
|
||||
.shadowenv.d
|
||||
.vscode
|
||||
node_modules
|
||||
prisma
|
||||
public
|
||||
.shopify
|
||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Shopify app development
|
||||
|
||||
This app is scaffolded from a Shopify app template. See the README for framework-specific details.
|
||||
|
||||
Use the [Shopify AI Toolkit](https://shopify.dev/docs/apps/build/ai-toolkit) for all Shopify API and platform work. If missing, install it in the agent host per that page (or `npx skills add Shopify/shopify-ai-toolkit --list` for skill-compatible hosts) — do not add tooling to this repo.
|
||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@ -0,0 +1,4 @@
|
||||
{$APP_DOMAIN} {
|
||||
encode gzip
|
||||
reverse_proxy app:3000
|
||||
}
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
# Bundle Offers web app (admin, app proxy, webhooks, scheduler). The Shopify extensions are deployed
|
||||
# separately with `shopify app deploy`.
|
||||
FROM node:22-alpine AS build
|
||||
RUN apk add --no-cache openssl
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY extensions/bundle-discount/package.json extensions/bundle-discount/package.json
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx prisma generate && npm run build && npm prune --omit=dev
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache openssl
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
EXPOSE 3000
|
||||
# apply database migrations, then start
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npx react-router-serve ./build/server/index.js"]
|
||||
7
LICENSE.md
Normal file
7
LICENSE.md
Normal file
@ -0,0 +1,7 @@
|
||||
Copyright 2025-present, Shopify Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
113
README.md
Normal file
113
README.md
Normal file
@ -0,0 +1,113 @@
|
||||
# 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).
|
||||
16
app/db.server.ts
Normal file
16
app/db.server.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prismaGlobal: PrismaClient;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (!global.prismaGlobal) {
|
||||
global.prismaGlobal = new PrismaClient();
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = global.prismaGlobal ?? new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
61
app/entry.server.tsx
Normal file
61
app/entry.server.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { PassThrough } from "stream";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { type EntryContext } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import { addDocumentResponseHeaders } from "./shopify.server";
|
||||
import { startScheduler } from "./lib/scheduler.server";
|
||||
|
||||
// Re-publishes offer configurations at every start/end boundary (brief §11.5).
|
||||
startScheduler();
|
||||
|
||||
export const streamTimeout = 5000;
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
reactRouterContext: EntryContext
|
||||
) {
|
||||
addDocumentResponseHeaders(request, responseHeaders);
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
const callbackName = isbot(userAgent ?? '')
|
||||
? "onAllReady"
|
||||
: "onShellReady";
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter
|
||||
context={reactRouterContext}
|
||||
url={request.url}
|
||||
/>,
|
||||
{
|
||||
[callbackName]: () => {
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error) {
|
||||
responseStatusCode = 500;
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Automatically timeout the React renderer after 6 seconds, which ensures
|
||||
// React has enough time to flush down the rejected boundary contents
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
1
app/globals.d.ts
vendored
Normal file
1
app/globals.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
266
app/lib/compile.ts
Normal file
266
app/lib/compile.ts
Normal file
@ -0,0 +1,266 @@
|
||||
// Compiles the app's offers into what Shopify reads (brief §9, §11, §12, §18.2):
|
||||
// * config -> discount metafield $app:configuration (spec/offer-config.schema.json), RUNNING offers only
|
||||
// * vars -> discount metafield $app:function-vars (input query variables of the function)
|
||||
// * storefront -> app-data metafield bundle_offers.storefront (theme app extension Liquid)
|
||||
// Pure: no database or network, so it is unit-tested (app/lib/compile.test.ts).
|
||||
import { num, offerStatus, offerSummary, moneyFormatter } from "./offer-text";
|
||||
import { planOf } from "./plans";
|
||||
|
||||
export const CONFIG_MAX_BYTES = 10_000; // function metafields above ~10 kB are not delivered (verified Oct 2026)
|
||||
export const STOREFRONT_MAX_BYTES = 60_000;
|
||||
export const MAX_COLLECTIONS = 100; // function input variables: lists over 100 elements fail
|
||||
export const NO_GUARD = "2999-12-31T23:59:59";
|
||||
|
||||
export interface CompileLine { productId: string; variantId: string | null; quantity: number; handle?: string | null; imageUrl?: string | null; title?: string | null }
|
||||
export interface CompileScope { role: string; kind: string; refId: string; parentId?: string | null; handle?: string | null; imageUrl?: string | null; title?: string | null }
|
||||
export interface CompileOffer {
|
||||
id: string;
|
||||
code: string;
|
||||
handle: string;
|
||||
name: string;
|
||||
badge: string | null;
|
||||
description?: string | null;
|
||||
i18n?: unknown;
|
||||
sequence: number;
|
||||
active: boolean;
|
||||
published: boolean;
|
||||
countries: string[];
|
||||
type: string;
|
||||
discountType: string;
|
||||
discountValue: number | string | { toString(): string };
|
||||
mixQty: number;
|
||||
buyQty: number;
|
||||
getQty: number;
|
||||
getPercent: number | string | { toString(): string };
|
||||
startsAt: Date | null;
|
||||
endsAt: Date | null;
|
||||
maxPerOrder: number;
|
||||
showHome: boolean;
|
||||
showProduct: boolean;
|
||||
showCartHints: boolean;
|
||||
imageUrl: string | null;
|
||||
createdAt: Date;
|
||||
lines: CompileLine[];
|
||||
scopes: CompileScope[];
|
||||
}
|
||||
|
||||
export interface ShopSettings {
|
||||
excludeTag?: string;
|
||||
excludeSubscriptions?: boolean;
|
||||
excludeGiftCards?: boolean;
|
||||
bxgyAllocation?: "all_units" | "get_units_only";
|
||||
combineProduct?: boolean;
|
||||
combineOrder?: boolean;
|
||||
combineShipping?: boolean;
|
||||
afterAdd?: "cart" | "drawer";
|
||||
accent?: string;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Required<ShopSettings> = {
|
||||
excludeTag: "no-bundle",
|
||||
excludeSubscriptions: true,
|
||||
excludeGiftCards: true,
|
||||
bxgyAllocation: "all_units",
|
||||
combineProduct: false,
|
||||
combineOrder: true,
|
||||
combineShipping: true,
|
||||
afterAdd: "cart",
|
||||
accent: "",
|
||||
radius: 8,
|
||||
};
|
||||
|
||||
export function settingsOf(raw: unknown): Required<ShopSettings> {
|
||||
return { ...DEFAULT_SETTINGS, ...((raw && typeof raw === "object" ? raw : {}) as ShopSettings) };
|
||||
}
|
||||
|
||||
export interface CompileShop {
|
||||
planCode: string;
|
||||
currency: string;
|
||||
timezone: string;
|
||||
settings: unknown;
|
||||
monthOrders: number;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
config: object;
|
||||
vars: { collectionIds: string[]; excludeTags: string[]; guardAt: string };
|
||||
storefront: object;
|
||||
configBytes: number;
|
||||
storefrontBytes: number;
|
||||
/** included (running, allowed) offer ids in priority order */
|
||||
included: string[];
|
||||
/** offer id -> reason it was left out although it should run */
|
||||
excluded: Record<string, string>;
|
||||
nextSyncAt: Date | null;
|
||||
/** blocking problem: publishing must be refused */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Odoo order: sequence, date_start desc (nulls first, like Postgres), id desc (newer first). */
|
||||
export function byPriority(a: CompileOffer, b: CompileOffer) {
|
||||
if (a.sequence !== b.sequence) return a.sequence - b.sequence;
|
||||
const sa = a.startsAt ? a.startsAt.getTime() : Infinity;
|
||||
const sb = b.startsAt ? b.startsAt.getTime() : Infinity;
|
||||
if (sa !== sb) return sb - sa;
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
}
|
||||
|
||||
const iso = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
/** "YYYY-MM-DDTHH:mm:ss" in the shop timezone (DateTimeWithoutTimezone for shop.localTime). */
|
||||
export function toShopLocal(d: Date, timezone: string) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23",
|
||||
}).formatToParts(d);
|
||||
const p = Object.fromEntries(parts.map((x) => [x.type, x.value]));
|
||||
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`;
|
||||
} catch {
|
||||
return iso(d).slice(0, 19);
|
||||
}
|
||||
}
|
||||
|
||||
function scopeOf(scopes: CompileScope[], role: string) {
|
||||
const s: { v?: string[]; p?: string[]; c?: string[] } = {};
|
||||
const add = (k: "v" | "p" | "c", id: string) => { (s[k] ||= []).includes(id) || s[k]!.push(id); };
|
||||
for (const x of scopes) {
|
||||
if (x.role !== role) continue;
|
||||
if (x.kind === "variant") add("v", x.refId);
|
||||
else if (x.kind === "product") add("p", x.refId);
|
||||
else if (x.kind === "collection") add("c", x.refId);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export function compile(shop: CompileShop, offers: CompileOffer[], now = new Date()): CompileResult {
|
||||
const plan = planOf(shop.planCode);
|
||||
const settings = settingsOf(shop.settings);
|
||||
const money = moneyFormatter(shop.currency);
|
||||
const excluded: Record<string, string> = {};
|
||||
|
||||
// Next boundary: any start or end still ahead, for offers that are switched on.
|
||||
let nextSyncAt: Date | null = null;
|
||||
for (const o of offers) {
|
||||
if (!o.active || !o.published) continue;
|
||||
for (const d of [o.startsAt, o.endsAt]) {
|
||||
if (d && d > now && (!nextSyncAt || d < nextSyncAt)) nextSyncAt = d;
|
||||
}
|
||||
}
|
||||
|
||||
const running = offers.filter((o) => offerStatus(o, now) === "running").sort(byPriority);
|
||||
const included: CompileOffer[] = [];
|
||||
const freeOrdersExceeded = shop.monthOrders >= plan.monthlyOrders;
|
||||
for (const o of running) {
|
||||
if (freeOrdersExceeded) { excluded[o.id] = `The ${plan.name} plan includes ${plan.monthlyOrders} bundle orders a month.`; continue; }
|
||||
if (!plan.types.includes(o.type)) { excluded[o.id] = `${o.type === "mix" ? "Mix & match" : "Buy X get Y"} bundles need a paid plan.`; continue; }
|
||||
if (!plan.scheduling && (o.startsAt || o.endsAt)) { excluded[o.id] = "Start and end dates need a paid plan."; continue; }
|
||||
if (!plan.markets && o.countries.length) { excluded[o.id] = "Market targeting needs the Growth plan."; continue; }
|
||||
if (included.length >= plan.runningOffers) { excluded[o.id] = `The ${plan.name} plan allows ${plan.runningOffers} running offer(s).`; continue; }
|
||||
included.push(o);
|
||||
}
|
||||
|
||||
// ---- function configuration ----
|
||||
const ends = included.map((o) => o.endsAt).filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime());
|
||||
const guard = ends[0] || null;
|
||||
const collectionIds = new Set<string>();
|
||||
const cfgOffers = included.map((o) => {
|
||||
const x: Record<string, unknown> = { id: o.code, n: o.name.slice(0, 80), t: o.type };
|
||||
if (o.endsAt) x.e = iso(o.endsAt);
|
||||
if (o.maxPerOrder > 0) x.mu = o.maxPerOrder;
|
||||
if (o.countries.length) x.cc = o.countries;
|
||||
if (o.type === "fixed" || o.type === "mix") { x.dt = o.discountType; x.dv = num(o.discountValue); }
|
||||
if (o.type === "fixed") x.l = o.lines.map((l) => (l.variantId ? { v: l.variantId, q: l.quantity } : { p: l.productId, q: l.quantity }));
|
||||
if (o.type === "mix") { x.q = o.mixQty; x.sc = scopeOf(o.scopes, "mix"); }
|
||||
if (o.type === "bxgy") {
|
||||
x.bq = o.buyQty; x.gq = o.getQty; x.gp = num(o.getPercent);
|
||||
x.bs = scopeOf(o.scopes, "buy"); x.gs = scopeOf(o.scopes, "get");
|
||||
}
|
||||
for (const s of o.scopes) if (s.kind === "collection") collectionIds.add(s.refId);
|
||||
return x;
|
||||
});
|
||||
const config: Record<string, unknown> = {
|
||||
v: 1,
|
||||
cur: shop.currency,
|
||||
ex: { subs: settings.excludeSubscriptions, gc: settings.excludeGiftCards },
|
||||
offers: cfgOffers,
|
||||
};
|
||||
if (settings.excludeTag) (config.ex as Record<string, unknown>).tag = settings.excludeTag;
|
||||
if (settings.bxgyAllocation === "get_units_only") config.ba = "get";
|
||||
if (guard) config.g = iso(guard);
|
||||
|
||||
const vars = {
|
||||
collectionIds: [...collectionIds].map((id) => `gid://shopify/Collection/${id}`),
|
||||
excludeTags: settings.excludeTag ? [settings.excludeTag] : [],
|
||||
guardAt: guard ? toShopLocal(guard, shop.timezone) : NO_GUARD,
|
||||
};
|
||||
|
||||
// ---- storefront data (cards, product box, cart hints) ----
|
||||
const sfOffers = included.map((o) => {
|
||||
const thumbs = [...o.lines.map((l) => l.imageUrl), ...o.scopes.map((s) => s.imageUrl)].filter(Boolean).slice(0, 4);
|
||||
const x: Record<string, unknown> = {
|
||||
id: o.code,
|
||||
h: o.handle,
|
||||
n: o.name,
|
||||
b: o.badge || "",
|
||||
s: offerSummary(o, money),
|
||||
t: o.type,
|
||||
img: o.imageUrl || "",
|
||||
th: thumbs,
|
||||
e: o.endsAt ? iso(o.endsAt) : "",
|
||||
f: [o.showHome && plan.homepage ? 1 : 0, o.showProduct ? 1 : 0, o.showCartHints && plan.cartHints ? 1 : 0],
|
||||
};
|
||||
if (o.countries.length) x.cc = o.countries;
|
||||
if (o.type === "fixed" || o.type === "mix") { x.dt = o.discountType; x.dv = round2(num(o.discountValue)); }
|
||||
if (o.type === "fixed") {
|
||||
x.l = o.lines.map((l) => ({ p: l.productId, v: l.variantId || "", q: l.quantity, h: l.handle || "", tt: l.title || "" }));
|
||||
x.ni = o.lines.reduce((s, l) => s + l.quantity, 0);
|
||||
}
|
||||
if (o.type === "mix") { x.q = o.mixQty; x.sc = scopeOf(o.scopes, "mix"); }
|
||||
if (o.type === "bxgy") { x.bq = o.buyQty; x.gq = o.getQty; x.gp = num(o.getPercent); x.bs = scopeOf(o.scopes, "buy"); x.gs = scopeOf(o.scopes, "get"); }
|
||||
const i18n = (o.i18n && typeof o.i18n === "object" ? o.i18n : {}) as Record<string, { name?: string; badge?: string }>;
|
||||
if (plan.translations) {
|
||||
const tr: Record<string, { n?: string; b?: string }> = {};
|
||||
for (const [loc, t] of Object.entries(i18n)) if (t?.name || t?.badge) tr[loc] = { n: t.name || undefined, b: t.badge || undefined };
|
||||
if (Object.keys(tr).length) x.i = tr;
|
||||
}
|
||||
return x;
|
||||
});
|
||||
const storefront = {
|
||||
v: 1,
|
||||
cur: shop.currency,
|
||||
brand: plan.branding,
|
||||
after: settings.afterAdd,
|
||||
accent: settings.accent,
|
||||
radius: settings.radius,
|
||||
offers: sfOffers,
|
||||
};
|
||||
|
||||
const configJson = JSON.stringify(config);
|
||||
const storefrontJson = JSON.stringify(storefront);
|
||||
const configBytes = new TextEncoder().encode(configJson).length;
|
||||
const storefrontBytes = new TextEncoder().encode(storefrontJson).length;
|
||||
let error: string | null = null;
|
||||
if (configBytes > CONFIG_MAX_BYTES) {
|
||||
error = `Too many running offers for one discount (${configBytes.toLocaleString()} of ${CONFIG_MAX_BYTES.toLocaleString()} bytes). End or merge some offers.`;
|
||||
} else if (vars.collectionIds.length > MAX_COLLECTIONS) {
|
||||
error = `Running offers use ${vars.collectionIds.length} collections; the limit is ${MAX_COLLECTIONS}. Use fewer collections.`;
|
||||
} else if (storefrontBytes > STOREFRONT_MAX_BYTES) {
|
||||
error = "Too much storefront data. Shorten offer names or reduce the number of running offers.";
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
vars,
|
||||
storefront,
|
||||
configBytes,
|
||||
storefrontBytes,
|
||||
included: included.map((o) => o.id),
|
||||
excluded,
|
||||
nextSyncAt,
|
||||
error,
|
||||
};
|
||||
}
|
||||
112
app/lib/offer-text.ts
Normal file
112
app/lib/offer-text.ts
Normal file
@ -0,0 +1,112 @@
|
||||
// Offer texts, status and display prices (brief §3, §5). Shared by the admin UI, the config compiler and
|
||||
// the app proxy pages. Keep wording identical to the Odoo module.
|
||||
|
||||
export type BundleType = "fixed" | "mix" | "bxgy";
|
||||
export type DiscountType = "percent" | "amount" | "fixed_price";
|
||||
export type OfferStatus = "draft" | "scheduled" | "running" | "expired";
|
||||
|
||||
export interface OfferLike {
|
||||
type: BundleType | string;
|
||||
discountType: DiscountType | string;
|
||||
discountValue: number | string | { toString(): string };
|
||||
mixQty: number;
|
||||
buyQty: number;
|
||||
getQty: number;
|
||||
getPercent: number | string | { toString(): string };
|
||||
active: boolean;
|
||||
published: boolean;
|
||||
startsAt: Date | string | null;
|
||||
endsAt: Date | string | null;
|
||||
}
|
||||
|
||||
export const num = (v: unknown) => Number(typeof v === "object" && v !== null ? v.toString() : v) || 0;
|
||||
|
||||
/** %g: no trailing zeros */
|
||||
export function pct(v: unknown) {
|
||||
const n = num(v);
|
||||
return String(Number(n.toFixed(4)));
|
||||
}
|
||||
|
||||
export type MoneyFormatter = (amount: number) => string;
|
||||
|
||||
export function moneyFormatter(currency: string, locale = "en"): MoneyFormatter {
|
||||
try {
|
||||
const f = new Intl.NumberFormat(locale, { style: "currency", currency });
|
||||
return (a) => f.format(a);
|
||||
} catch {
|
||||
return (a) => `${a.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Summary text (§5). */
|
||||
export function offerSummary(o: OfferLike, money: MoneyFormatter): string {
|
||||
const v = num(o.discountValue);
|
||||
if (o.type === "bxgy") {
|
||||
const gp = num(o.getPercent);
|
||||
return gp >= 100
|
||||
? `Buy ${o.buyQty}, get ${o.getQty} free`
|
||||
: `Buy ${o.buyQty}, get ${o.getQty} at ${pct(gp)}% off`;
|
||||
}
|
||||
if (o.type === "mix") {
|
||||
if (o.discountType === "fixed_price") return `Any ${o.mixQty} for ${money(v)}`;
|
||||
if (o.discountType === "amount") return `${money(v)} off any ${o.mixQty}`;
|
||||
return `${pct(v)}% off any ${o.mixQty}`;
|
||||
}
|
||||
if (o.discountType === "fixed_price") return `Bundle price ${money(v)}`;
|
||||
if (o.discountType === "amount") return `Save ${money(v)} on the bundle`;
|
||||
return `Save ${pct(v)}% on the bundle`;
|
||||
}
|
||||
|
||||
const toDate = (d: Date | string | null) => (d ? new Date(d) : null);
|
||||
|
||||
/** draft if inactive or unpublished; expired if end < now; scheduled if start > now; else running. */
|
||||
export function offerStatus(o: Pick<OfferLike, "active" | "published" | "startsAt" | "endsAt">, now = new Date()): OfferStatus {
|
||||
if (!o.active || !o.published) return "draft";
|
||||
const end = toDate(o.endsAt);
|
||||
const start = toDate(o.startsAt);
|
||||
if (end && end < now) return "expired";
|
||||
if (start && start > now) return "scheduled";
|
||||
return "running";
|
||||
}
|
||||
|
||||
export const STATUS_LABEL: Record<OfferStatus, string> = {
|
||||
draft: "Draft",
|
||||
scheduled: "Scheduled",
|
||||
running: "Running",
|
||||
expired: "Ended",
|
||||
};
|
||||
|
||||
export const STATUS_TONE: Record<OfferStatus, "neutral" | "info" | "success" | "warning"> = {
|
||||
draft: "neutral",
|
||||
scheduled: "info",
|
||||
running: "success",
|
||||
expired: "warning",
|
||||
};
|
||||
|
||||
export const TYPE_LABEL: Record<string, string> = {
|
||||
fixed: "Fixed bundle",
|
||||
mix: "Mix & match",
|
||||
bxgy: "Buy X get Y",
|
||||
};
|
||||
|
||||
/** Fixed bundle display prices (§5): saving and bundle price from the regular price. */
|
||||
export function fixedBundlePrices(regular: number, discountType: string, value: number) {
|
||||
let saving = 0;
|
||||
if (discountType === "percent") saving = (regular * Math.min(value, 100)) / 100;
|
||||
else if (discountType === "amount") saving = Math.min(value, regular);
|
||||
else if (discountType === "fixed_price") saving = Math.max(regular - value, 0);
|
||||
saving = Math.max(saving, 0);
|
||||
return { regular, saving, bundle: regular - saving };
|
||||
}
|
||||
|
||||
export function slugify(name: string) {
|
||||
return (
|
||||
name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "bundle"
|
||||
);
|
||||
}
|
||||
196
app/lib/offers.server.ts
Normal file
196
app/lib/offers.server.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import db from "../db.server";
|
||||
import { slugify } from "./offer-text";
|
||||
import { validateOffer, type Errors, type OfferInput } from "./validation";
|
||||
|
||||
export interface OfferFormLine { productId: string; variantId?: string | null; quantity: number; title?: string | null; handle?: string | null; imageUrl?: string | null }
|
||||
export interface OfferFormScope { role: string; kind: string; refId: string; parentId?: string | null; title?: string | null; handle?: string | null; imageUrl?: string | null }
|
||||
export interface OfferForm extends OfferInput {
|
||||
published: boolean;
|
||||
active?: boolean;
|
||||
countries: string[];
|
||||
showHome: boolean;
|
||||
showProduct: boolean;
|
||||
showCartHints: boolean;
|
||||
imageUrl?: string | null;
|
||||
i18n?: Record<string, { name?: string; badge?: string; description?: string }>;
|
||||
lines: OfferFormLine[];
|
||||
scopes: OfferFormScope[];
|
||||
}
|
||||
|
||||
export const offerInclude = {
|
||||
lines: { orderBy: { sequence: "asc" } },
|
||||
scopes: true,
|
||||
} satisfies Prisma.OfferInclude;
|
||||
|
||||
export type OfferWithRelations = Prisma.OfferGetPayload<{ include: typeof offerInclude }>;
|
||||
|
||||
const TRACKED = ["name", "type", "discountType", "discountValue", "startsAt", "endsAt", "published", "active"] as const;
|
||||
|
||||
async function uniqueHandle(shopId: string, name: string, exceptId?: string) {
|
||||
const base = slugify(name);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const handle = i ? `${base}-${i + 1}` : base;
|
||||
const clash = await db.offer.findFirst({
|
||||
where: { shopId, id: exceptId ? { not: exceptId } : undefined, OR: [{ handle }, { oldHandles: { has: handle } }] },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!clash) return handle;
|
||||
}
|
||||
return `${base}-${Date.now()}`;
|
||||
}
|
||||
|
||||
async function nextCode(shopId: string) {
|
||||
const count = await db.offer.count({ where: { shopId } });
|
||||
for (let i = count + 1; ; i += 1) {
|
||||
const code = `o${i.toString(36)}`;
|
||||
if (!(await db.offer.findFirst({ where: { shopId, code }, select: { id: true } }))) return code;
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (v: unknown) => (v instanceof Date ? v.toISOString() : v == null ? "" : String(v));
|
||||
|
||||
function data(form: OfferForm) {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
badge: form.badge?.trim() || null,
|
||||
description: form.description?.trim() || null,
|
||||
i18n: (form.i18n || {}) as Prisma.InputJsonValue,
|
||||
sequence: Math.round(form.sequence) || 10,
|
||||
published: !!form.published,
|
||||
active: form.active ?? true,
|
||||
countries: form.countries.map((c) => c.trim().toUpperCase()).filter((c) => /^[A-Z]{2}$/.test(c)),
|
||||
type: form.type,
|
||||
discountType: form.discountType,
|
||||
discountValue: Number(form.discountValue) || 0,
|
||||
mixQty: Math.round(form.mixQty),
|
||||
buyQty: Math.round(form.buyQty),
|
||||
getQty: Math.round(form.getQty),
|
||||
getPercent: Number(form.getPercent) || 0,
|
||||
startsAt: form.startsAt ? new Date(form.startsAt) : null,
|
||||
endsAt: form.endsAt ? new Date(form.endsAt) : null,
|
||||
maxPerOrder: Math.round(form.maxPerOrder) || 0,
|
||||
showHome: !!form.showHome,
|
||||
showProduct: !!form.showProduct,
|
||||
showCartHints: !!form.showCartHints,
|
||||
imageUrl: form.imageUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveOffer(shopId: string, id: string | null, form: OfferForm, staffUser?: string): Promise<{ errors?: Errors; offer?: OfferWithRelations }> {
|
||||
const errors = validateOffer(form);
|
||||
if (Object.keys(errors).length) return { errors };
|
||||
const values = data(form);
|
||||
// keep only the relevant products for the chosen type
|
||||
const lines = form.type === "fixed" ? form.lines : [];
|
||||
const scopes = form.scopes.filter((s) => (form.type === "mix" ? s.role === "mix" : form.type === "bxgy" ? s.role === "buy" || s.role === "get" : false));
|
||||
|
||||
const offer = await db.$transaction(async (tx) => {
|
||||
const before = id ? await tx.offer.findFirst({ where: { id, shopId } }) : null;
|
||||
if (id && !before) throw new Response("Not found", { status: 404 });
|
||||
let handle = before?.handle;
|
||||
let oldHandles = before?.oldHandles || [];
|
||||
if (!before || before.name !== values.name) {
|
||||
const next = await uniqueHandle(shopId, values.name, id || undefined);
|
||||
if (before && next !== before.handle) oldHandles = [...new Set([...oldHandles, before.handle])].filter((h) => h !== next);
|
||||
handle = next;
|
||||
}
|
||||
const saved = before
|
||||
? await tx.offer.update({ where: { id: before.id }, data: { ...values, handle, oldHandles } })
|
||||
: await tx.offer.create({ data: { ...values, shopId, handle: handle!, code: await nextCode(shopId) } });
|
||||
|
||||
await tx.offerLine.deleteMany({ where: { offerId: saved.id } });
|
||||
await tx.offerScope.deleteMany({ where: { offerId: saved.id } });
|
||||
if (lines.length) {
|
||||
await tx.offerLine.createMany({
|
||||
data: lines.map((l, i) => ({
|
||||
offerId: saved.id, sequence: (i + 1) * 10, productId: String(l.productId), variantId: l.variantId || null,
|
||||
quantity: Math.round(l.quantity), title: l.title || null, handle: l.handle || null, imageUrl: l.imageUrl || null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (scopes.length) {
|
||||
await tx.offerScope.createMany({
|
||||
data: scopes.map((s) => ({
|
||||
offerId: saved.id, role: s.role, kind: s.kind, refId: String(s.refId), parentId: s.parentId || null,
|
||||
title: s.title || null, handle: s.handle || null, imageUrl: s.imageUrl || null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const audit = TRACKED.filter((f) => fmt(before?.[f]) !== fmt(saved[f]))
|
||||
.filter((f) => before || ["name", "type", "published"].includes(f))
|
||||
.map((f) => ({ shopId, offerId: saved.id, staffUser: staffUser || null, field: f, oldValue: before ? fmt(before[f]) : null, newValue: fmt(saved[f]) }));
|
||||
if (audit.length) await tx.auditLog.createMany({ data: audit });
|
||||
|
||||
return tx.offer.findUniqueOrThrow({ where: { id: saved.id }, include: offerInclude });
|
||||
});
|
||||
return { offer };
|
||||
}
|
||||
|
||||
export async function duplicateOffer(shopId: string, id: string, staffUser?: string) {
|
||||
const o = await db.offer.findFirst({ where: { id, shopId }, include: offerInclude });
|
||||
if (!o) return null;
|
||||
const { offer } = await saveOffer(shopId, null, {
|
||||
...o,
|
||||
name: `${o.name} (copy)`.slice(0, 80),
|
||||
discountValue: Number(o.discountValue),
|
||||
getPercent: Number(o.getPercent),
|
||||
startsAt: o.startsAt?.toISOString() || null,
|
||||
endsAt: o.endsAt?.toISOString() || null,
|
||||
published: false,
|
||||
i18n: o.i18n as OfferForm["i18n"],
|
||||
}, staffUser);
|
||||
return offer;
|
||||
}
|
||||
|
||||
export async function bulkAction(shopId: string, ids: string[], action: string, staffUser?: string) {
|
||||
if (!ids.length) return;
|
||||
if (action === "duplicate") {
|
||||
for (const id of ids) await duplicateOffer(shopId, id, staffUser);
|
||||
return;
|
||||
}
|
||||
const change: Prisma.OfferUpdateManyMutationInput | null =
|
||||
action === "publish" ? { published: true, limitedByPlan: false }
|
||||
: action === "unpublish" ? { published: false }
|
||||
: action === "archive" ? { active: false, published: false }
|
||||
: action === "unarchive" ? { active: true }
|
||||
: null;
|
||||
if (change) {
|
||||
await db.offer.updateMany({ where: { shopId, id: { in: ids } }, data: change });
|
||||
await db.auditLog.createMany({ data: ids.map((offerId) => ({ shopId, offerId, staffUser: staffUser || null, field: action, newValue: "bulk" })) });
|
||||
} else if (action === "delete") {
|
||||
await db.offer.deleteMany({ where: { shopId, id: { in: ids }, active: false } }); // only archived offers
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves an offer one step up/down in priority; renumbers sequences 10, 20, 30… */
|
||||
export async function moveOffer(shopId: string, id: string, direction: "up" | "down") {
|
||||
const offers = await db.offer.findMany({ where: { shopId, active: true }, orderBy: [{ sequence: "asc" }, { createdAt: "desc" }], select: { id: true } });
|
||||
const i = offers.findIndex((o) => o.id === id);
|
||||
const j = direction === "up" ? i - 1 : i + 1;
|
||||
if (i < 0 || j < 0 || j >= offers.length) return;
|
||||
[offers[i], offers[j]] = [offers[j], offers[i]];
|
||||
await db.$transaction(offers.map((o, k) => db.offer.update({ where: { id: o.id }, data: { sequence: (k + 1) * 10 } })));
|
||||
}
|
||||
|
||||
/** Plan downgrade: keep all offers, unpublish the lowest-priority ones above the limit (§18.2). */
|
||||
export async function enforcePlanOnPublished(shopId: string, limit: number, allowedTypes: string[]) {
|
||||
const published = await db.offer.findMany({
|
||||
where: { shopId, active: true, published: true },
|
||||
orderBy: [{ sequence: "asc" }, { startsAt: "desc" }, { createdAt: "desc" }],
|
||||
select: { id: true, type: true, endsAt: true },
|
||||
});
|
||||
const now = new Date();
|
||||
const live = published.filter((o) => !o.endsAt || o.endsAt > now);
|
||||
const toUnpublish: string[] = [];
|
||||
let kept = 0;
|
||||
for (const o of live) {
|
||||
if (!allowedTypes.includes(o.type) || kept >= limit) toUnpublish.push(o.id);
|
||||
else kept += 1;
|
||||
}
|
||||
if (toUnpublish.length) {
|
||||
await db.offer.updateMany({ where: { id: { in: toUnpublish } }, data: { published: false, limitedByPlan: true } });
|
||||
}
|
||||
return toUnpublish.length;
|
||||
}
|
||||
72
app/lib/orders.server.ts
Normal file
72
app/lib/orders.server.ts
Normal file
@ -0,0 +1,72 @@
|
||||
// orders/create -> analytics (F23, N02). The function's candidate message is the offer name, or "A + B" when
|
||||
// one line carries two offers; we attribute by name. If Shopify reports only the discount title
|
||||
// ("Bundle Offers"), the usage is kept under that title. Verify the exact title on a dev store (README).
|
||||
import db from "../db.server";
|
||||
|
||||
export interface OrderPayload {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
currency?: string;
|
||||
total_price?: string;
|
||||
discount_applications?: { type?: string; title?: string; description?: string | null }[];
|
||||
line_items?: {
|
||||
price?: string;
|
||||
quantity?: number;
|
||||
discount_allocations?: { amount?: string; discount_application_index?: number }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function recordOrder(
|
||||
shopId: string,
|
||||
order: OrderPayload,
|
||||
offers: { id: string; name: string }[],
|
||||
discountTitle: string,
|
||||
) {
|
||||
const orderId = String(order.id);
|
||||
const apps = order.discount_applications || [];
|
||||
const byName = new Map(offers.map((o) => [o.name, o.id]));
|
||||
const ours = new Map<number, string[]>(); // application index -> offer names
|
||||
apps.forEach((a, i) => {
|
||||
const title = (a.title || a.description || "").trim();
|
||||
if (!title) return;
|
||||
const parts = title.split(" + ").map((s) => s.trim());
|
||||
if (parts.every((p) => byName.has(p))) ours.set(i, parts);
|
||||
else if (title === discountTitle && a.type !== "discount_code") ours.set(i, [discountTitle]);
|
||||
});
|
||||
|
||||
const perOffer = new Map<string, { discount: number; revenue: number }>();
|
||||
for (const li of order.line_items || []) {
|
||||
const lineTotal = Number(li.price || 0) * (li.quantity || 0);
|
||||
for (const al of li.discount_allocations || []) {
|
||||
const names = ours.get(al.discount_application_index ?? -1);
|
||||
if (!names) continue;
|
||||
const amount = Number(al.amount || 0);
|
||||
// a merged line ("A + B"): split evenly, we don't know the per-offer share
|
||||
for (const n of names) {
|
||||
const e = perOffer.get(n) || { discount: 0, revenue: 0 };
|
||||
e.discount += amount / names.length;
|
||||
e.revenue += lineTotal / names.length;
|
||||
perOffer.set(n, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasBundle = perOffer.size > 0;
|
||||
const currency = order.currency || "USD";
|
||||
const total = Number(order.total_price || 0);
|
||||
const existing = await db.orderStat.findUnique({ where: { shopId_orderId: { shopId, orderId } } });
|
||||
if (!existing) {
|
||||
await db.orderStat.create({ data: { shopId, orderId, total, hasBundle, currency } });
|
||||
}
|
||||
for (const [name, v] of perOffer) {
|
||||
await db.orderUsage.upsert({
|
||||
where: { shopId_orderId_offerName: { shopId, orderId, offerName: name } },
|
||||
create: {
|
||||
shopId, orderId, orderName: order.name || null, offerId: byName.get(name) || null, offerName: name,
|
||||
discount: Math.round(v.discount * 100) / 100, bundleRevenue: Math.round(v.revenue * 100) / 100, orderTotal: total, currency,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
return { hasBundle, isNew: !existing };
|
||||
}
|
||||
54
app/lib/plans.ts
Normal file
54
app/lib/plans.ts
Normal file
@ -0,0 +1,54 @@
|
||||
// Plans (brief §18). Prices live in the Partner Dashboard (managed pricing); limits are enforced here,
|
||||
// server-side, when publishing and when compiling the function configuration.
|
||||
|
||||
export type PlanCode = "free" | "starter" | "growth" | "plus";
|
||||
|
||||
export interface PlanLimits {
|
||||
name: string;
|
||||
price: string;
|
||||
runningOffers: number; // Infinity = unlimited (within config size)
|
||||
types: string[];
|
||||
scheduling: boolean;
|
||||
homepage: boolean;
|
||||
cartHints: boolean;
|
||||
branding: boolean;
|
||||
monthlyOrders: number;
|
||||
templates: boolean;
|
||||
analytics: boolean;
|
||||
markets: boolean;
|
||||
translations: boolean;
|
||||
}
|
||||
|
||||
const ALL_TYPES = ["fixed", "mix", "bxgy"];
|
||||
|
||||
export const PLANS: Record<PlanCode, PlanLimits> = {
|
||||
free: {
|
||||
name: "Free", price: "$0", runningOffers: 1, types: ["fixed"], scheduling: false, homepage: false,
|
||||
cartHints: false, branding: true, monthlyOrders: 50, templates: false, analytics: false, markets: false, translations: false,
|
||||
},
|
||||
starter: {
|
||||
name: "Starter", price: "$9.99/mo", runningOffers: 5, types: ALL_TYPES, scheduling: true, homepage: true,
|
||||
cartHints: true, branding: false, monthlyOrders: Infinity, templates: false, analytics: false, markets: false, translations: false,
|
||||
},
|
||||
growth: {
|
||||
name: "Growth", price: "$24.99/mo", runningOffers: Infinity, types: ALL_TYPES, scheduling: true, homepage: true,
|
||||
cartHints: true, branding: false, monthlyOrders: Infinity, templates: true, analytics: true, markets: true, translations: true,
|
||||
},
|
||||
plus: {
|
||||
name: "Plus", price: "$59.99/mo", runningOffers: Infinity, types: ALL_TYPES, scheduling: true, homepage: true,
|
||||
cartHints: true, branding: false, monthlyOrders: Infinity, templates: true, analytics: true, markets: true, translations: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function planOf(code: string | null | undefined): PlanLimits {
|
||||
return PLANS[(code as PlanCode) in PLANS ? (code as PlanCode) : "free"];
|
||||
}
|
||||
|
||||
/** Maps a managed-pricing subscription name (Partner Dashboard plan name) to a plan code. */
|
||||
export function planCodeFromSubscription(name: string | null | undefined): PlanCode {
|
||||
const n = (name || "").toLowerCase();
|
||||
if (n.includes("plus")) return "plus";
|
||||
if (n.includes("growth")) return "growth";
|
||||
if (n.includes("starter")) return "starter";
|
||||
return "free";
|
||||
}
|
||||
28
app/lib/proxy.server.ts
Normal file
28
app/lib/proxy.server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
// Shared loading for the app proxy pages. Public: never depends on a logged-in customer (brief §8).
|
||||
import db from "../db.server";
|
||||
import { offerInclude } from "./offers.server";
|
||||
import { compile } from "./compile";
|
||||
import { planOf } from "./plans";
|
||||
|
||||
export const PROXY_CACHE = "public, max-age=60";
|
||||
|
||||
export async function runningOffersForShop(domain: string, country: string | null) {
|
||||
const shop = await db.shop.findUnique({ where: { domain } });
|
||||
if (!shop || shop.uninstalledAt) return null;
|
||||
const offers = await db.offer.findMany({ where: { shopId: shop.id, active: true, published: true }, include: offerInclude });
|
||||
// Same selection as the discount function: running + allowed by the plan.
|
||||
const result = compile(
|
||||
{ planCode: shop.planCode, currency: shop.currency, timezone: shop.timezone, settings: shop.settings, monthOrders: 0 },
|
||||
offers,
|
||||
);
|
||||
const included = new Set(result.included);
|
||||
const running = offers
|
||||
.filter((o) => included.has(o.id))
|
||||
.filter((o) => !o.countries.length || (country && o.countries.includes(country)))
|
||||
.sort((a, b) => result.included.indexOf(a.id) - result.included.indexOf(b.id));
|
||||
return {
|
||||
shop,
|
||||
running,
|
||||
ctx: { currency: shop.currency, timezone: shop.timezone, branding: planOf(shop.planCode).branding },
|
||||
};
|
||||
}
|
||||
170
app/lib/publish.server.ts
Normal file
170
app/lib/publish.server.ts
Normal file
@ -0,0 +1,170 @@
|
||||
// Publishes the compiled configuration to Shopify (brief §11.1, §11.5, §12):
|
||||
// ONE automatic app discount per shop (function "bundle-discount") carrying $app:configuration and
|
||||
// $app:function-vars, plus the app-data metafield bundle_offers.storefront read by the theme extension.
|
||||
import db from "../db.server";
|
||||
import { compile, settingsOf, type CompileResult } from "./compile";
|
||||
import { gql, ensureShop, type AdminLike } from "./shop.server";
|
||||
|
||||
export const FUNCTION_HANDLE = "bundle-discount";
|
||||
export const DISCOUNT_TITLE = "Bundle Offers";
|
||||
export const STOREFRONT_NAMESPACE = "bundle_offers";
|
||||
|
||||
const CREATE = `#graphql
|
||||
mutation BundleOffersCreateDiscount($d: DiscountAutomaticAppInput!) {
|
||||
discountAutomaticAppCreate(automaticAppDiscount: $d) {
|
||||
automaticAppDiscount { discountId }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
const UPDATE = `#graphql
|
||||
mutation BundleOffersUpdateDiscount($id: ID!, $d: DiscountAutomaticAppInput!) {
|
||||
discountAutomaticAppUpdate(id: $id, automaticAppDiscount: $d) {
|
||||
automaticAppDiscount { discountId }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
const NODE = `#graphql
|
||||
query BundleOffersDiscountNode($id: ID!) {
|
||||
discountNode(id: $id) { id discount { ... on DiscountAutomaticApp { status } } }
|
||||
}`;
|
||||
|
||||
const SET_METAFIELDS = `#graphql
|
||||
mutation BundleOffersSetMetafields($m: [MetafieldsSetInput!]!) {
|
||||
metafieldsSet(metafields: $m) { userErrors { field message code } }
|
||||
}`;
|
||||
|
||||
const DELETE_DISCOUNT = `#graphql
|
||||
mutation BundleOffersDeleteDiscount($id: ID!) {
|
||||
discountAutomaticDelete(id: $id) { deletedAutomaticDiscountId userErrors { field message } }
|
||||
}`;
|
||||
|
||||
const DELETE_METAFIELDS = `#graphql
|
||||
mutation BundleOffersDeleteMetafields($m: [MetafieldIdentifierInput!]!) {
|
||||
metafieldsDelete(metafields: $m) { userErrors { field message } }
|
||||
}`;
|
||||
|
||||
function combinesWith(settingsRaw: unknown) {
|
||||
const s = settingsOf(settingsRaw);
|
||||
return { productDiscounts: s.combineProduct, orderDiscounts: s.combineOrder, shippingDiscounts: s.combineShipping };
|
||||
}
|
||||
|
||||
function userErrorText(errors: { message: string }[] | undefined) {
|
||||
return errors?.length ? errors.map((e) => e.message).join("; ") : null;
|
||||
}
|
||||
|
||||
async function loadOffers(shopId: string) {
|
||||
return db.offer.findMany({ where: { shopId }, include: { lines: { orderBy: { sequence: "asc" } }, scopes: true } });
|
||||
}
|
||||
|
||||
export async function compileShop(shopId: string, now = new Date()): Promise<CompileResult> {
|
||||
const shop = await db.shop.findUniqueOrThrow({ where: { id: shopId } });
|
||||
const offers = await loadOffers(shopId);
|
||||
return compile(
|
||||
{ planCode: shop.planCode, currency: shop.currency, timezone: shop.timezone, settings: shop.settings, monthOrders: currentMonthOrders(shop) },
|
||||
offers.map((o) => ({ ...o, lines: o.lines, scopes: o.scopes })),
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function monthKey(d = new Date()) {
|
||||
return d.toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
export function currentMonthOrders(shop: { monthKey: string | null; monthOrders: number }) {
|
||||
return shop.monthKey === monthKey() ? shop.monthOrders : 0;
|
||||
}
|
||||
|
||||
/** Creates the shop's single automatic discount if it is missing; returns its id. */
|
||||
async function ensureDiscount(admin: AdminLike, shop: { id: string; discountNodeId: string | null; settings: unknown }, metafields: object[]) {
|
||||
if (shop.discountNodeId) {
|
||||
const node = await gql(admin, NODE, { id: shop.discountNodeId });
|
||||
if (node.discountNode) return { id: shop.discountNodeId, created: false };
|
||||
}
|
||||
const data = await gql(admin, CREATE, {
|
||||
d: {
|
||||
title: DISCOUNT_TITLE,
|
||||
functionHandle: FUNCTION_HANDLE,
|
||||
discountClasses: ["PRODUCT"],
|
||||
startsAt: new Date().toISOString(),
|
||||
endsAt: null,
|
||||
combinesWith: combinesWith(shop.settings),
|
||||
metafields,
|
||||
},
|
||||
});
|
||||
const err = userErrorText(data.discountAutomaticAppCreate.userErrors);
|
||||
if (err) throw new Error(`Could not create the Bundle Offers discount: ${err}`);
|
||||
const id = data.discountAutomaticAppCreate.automaticAppDiscount.discountId as string;
|
||||
await db.shop.update({ where: { id: shop.id }, data: { discountNodeId: id } });
|
||||
return { id, created: true };
|
||||
}
|
||||
|
||||
export interface PublishOutcome {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
result: CompileResult;
|
||||
}
|
||||
|
||||
/** Compiles and pushes everything to Shopify. Safe to call any time (idempotent). */
|
||||
export async function publishShop(admin: AdminLike, domain: string, now = new Date()): Promise<PublishOutcome> {
|
||||
const shop = await ensureShop(admin, domain);
|
||||
const result = await compileShop(shop.id, now);
|
||||
|
||||
if (result.error) {
|
||||
await db.shop.update({ where: { id: shop.id }, data: { publishError: result.error, nextSyncAt: result.nextSyncAt } });
|
||||
return { ok: false, error: result.error, result };
|
||||
}
|
||||
|
||||
const configMetafields = [
|
||||
{ namespace: "$app", key: "configuration", type: "json", value: JSON.stringify(result.config) },
|
||||
{ namespace: "$app", key: "function-vars", type: "json", value: JSON.stringify(result.vars) },
|
||||
];
|
||||
try {
|
||||
const discount = await ensureDiscount(admin, shop, configMetafields);
|
||||
const metafields: object[] = [
|
||||
{ ownerId: shop.appInstallationId, namespace: STOREFRONT_NAMESPACE, key: "storefront", type: "json", value: JSON.stringify(result.storefront) },
|
||||
];
|
||||
if (!discount.created) metafields.push(...configMetafields.map((m) => ({ ...m, ownerId: discount.id })));
|
||||
const set = await gql(admin, SET_METAFIELDS, { m: metafields });
|
||||
const err = userErrorText(set.metafieldsSet.userErrors);
|
||||
if (err) throw new Error(err);
|
||||
|
||||
await db.shop.update({
|
||||
where: { id: shop.id },
|
||||
data: {
|
||||
publishError: null,
|
||||
lastPublishedAt: now,
|
||||
nextSyncAt: result.nextSyncAt,
|
||||
configBytes: result.configBytes,
|
||||
storefrontBytes: result.storefrontBytes,
|
||||
},
|
||||
});
|
||||
return { ok: true, error: null, result };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
// retry in a minute
|
||||
await db.shop.update({ where: { id: shop.id }, data: { publishError: error, nextSyncAt: new Date(Date.now() + 60_000) } });
|
||||
return { ok: false, error, result };
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies the combine settings to the discount node. */
|
||||
export async function updateCombinations(admin: AdminLike, domain: string) {
|
||||
const shop = await ensureShop(admin, domain);
|
||||
if (!shop.discountNodeId) return publishShop(admin, domain);
|
||||
const data = await gql(admin, UPDATE, { id: shop.discountNodeId, d: { combinesWith: combinesWith(shop.settings) } });
|
||||
const err = userErrorText(data.discountAutomaticAppUpdate.userErrors);
|
||||
if (err) throw new Error(err);
|
||||
return publishShop(admin, domain);
|
||||
}
|
||||
|
||||
/** Removes the discount and the storefront metafield (uninstall from inside the app, or plan cleanup). */
|
||||
export async function removeShopifyData(admin: AdminLike, shop: { discountNodeId: string | null; appInstallationId: string | null }) {
|
||||
if (shop.discountNodeId) await gql(admin, DELETE_DISCOUNT, { id: shop.discountNodeId }).catch(() => null);
|
||||
if (shop.appInstallationId) {
|
||||
await gql(admin, DELETE_METAFIELDS, {
|
||||
m: [{ ownerId: shop.appInstallationId, namespace: STOREFRONT_NAMESPACE, key: "storefront" }],
|
||||
}).catch(() => null);
|
||||
}
|
||||
}
|
||||
44
app/lib/scheduler.server.ts
Normal file
44
app/lib/scheduler.server.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// Re-publishes a shop's configuration at every offer start/end boundary (brief §11.5).
|
||||
// Every 30 s it claims shops whose nextSyncAt has passed (atomic update, safe with several app instances)
|
||||
// and publishes them with the shop's offline token. The function's guard covers the seconds in between
|
||||
// for offers that END (see extensions/bundle-discount/src/config.rs), so no discount outlives its offer.
|
||||
import db from "../db.server";
|
||||
import { unauthenticated } from "../shopify.server";
|
||||
import { publishShop } from "./publish.server";
|
||||
|
||||
const TICK_MS = 30_000;
|
||||
|
||||
export async function runDueSyncs(now = new Date()) {
|
||||
const due = await db.shop.findMany({
|
||||
where: { uninstalledAt: null, nextSyncAt: { lte: now } },
|
||||
select: { id: true, domain: true, nextSyncAt: true },
|
||||
take: 50,
|
||||
});
|
||||
for (const shop of due) {
|
||||
// claim: only one instance gets each boundary
|
||||
const claimed = await db.shop.updateMany({
|
||||
where: { id: shop.id, nextSyncAt: shop.nextSyncAt },
|
||||
data: { nextSyncAt: new Date(now.getTime() + 5 * 60_000) }, // safety retry if this run dies
|
||||
});
|
||||
if (!claimed.count) continue;
|
||||
try {
|
||||
const { admin } = await unauthenticated.admin(shop.domain);
|
||||
await publishShop(admin, shop.domain, new Date());
|
||||
} catch (e) {
|
||||
console.error(`[scheduler] publish failed for ${shop.domain}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __bundleOffersScheduler: NodeJS.Timeout | undefined;
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
if (globalThis.__bundleOffersScheduler || process.env.DISABLE_SCHEDULER === "1") return;
|
||||
globalThis.__bundleOffersScheduler = setInterval(() => {
|
||||
runDueSyncs().catch((e) => console.error("[scheduler]", e));
|
||||
}, TICK_MS);
|
||||
globalThis.__bundleOffersScheduler.unref?.();
|
||||
}
|
||||
46
app/lib/shop.server.ts
Normal file
46
app/lib/shop.server.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import db from "../db.server";
|
||||
import { planCodeFromSubscription } from "./plans";
|
||||
|
||||
type GraphQL = (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
|
||||
export interface AdminLike { graphql: GraphQL }
|
||||
|
||||
export async function gql<T = any>(admin: AdminLike, query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await admin.graphql(query, variables ? { variables } : undefined);
|
||||
const json = (await res.json()) as { data?: T; errors?: unknown };
|
||||
if (json.errors) throw new Error(`GraphQL error: ${JSON.stringify(json.errors)}`);
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
const SHOP_QUERY = `#graphql
|
||||
query BundleOffersShop {
|
||||
shop { currencyCode ianaTimezone currencyFormats { moneyFormat } }
|
||||
currentAppInstallation { id activeSubscriptions { name status test } }
|
||||
}`;
|
||||
|
||||
/** Creates/refreshes the Shop row from the Admin API (currency, timezone, plan). */
|
||||
export async function syncShop(admin: AdminLike, domain: string) {
|
||||
const data = await gql(admin, SHOP_QUERY);
|
||||
const subs = (data.currentAppInstallation?.activeSubscriptions || []) as { name: string; status: string }[];
|
||||
const active = subs.find((s) => s.status === "ACTIVE");
|
||||
const planCode = planCodeFromSubscription(active?.name);
|
||||
const values = {
|
||||
currency: data.shop.currencyCode,
|
||||
timezone: data.shop.ianaTimezone || "UTC",
|
||||
moneyFormat: data.shop.currencyFormats?.moneyFormat || "${{amount}}",
|
||||
appInstallationId: data.currentAppInstallation?.id,
|
||||
planCode,
|
||||
uninstalledAt: null,
|
||||
};
|
||||
return db.shop.upsert({ where: { domain }, create: { domain, ...values }, update: values });
|
||||
}
|
||||
|
||||
export async function getShop(domain: string) {
|
||||
return db.shop.findUnique({ where: { domain } });
|
||||
}
|
||||
|
||||
/** Shop row for an admin request; syncs from Shopify the first time (or when forced). */
|
||||
export async function ensureShop(admin: AdminLike, domain: string, force = false) {
|
||||
const shop = await getShop(domain);
|
||||
if (shop && !force && shop.appInstallationId && !shop.uninstalledAt) return shop;
|
||||
return syncShop(admin, domain);
|
||||
}
|
||||
235
app/lib/storefront.server.ts
Normal file
235
app/lib/storefront.server.ts
Normal file
@ -0,0 +1,235 @@
|
||||
// Liquid for the app proxy pages (/apps/bundles and /apps/bundles/{handle}), brief §6.1–6.2, F12–F14, F19.
|
||||
// Rendered by Shopify inside the theme layout, for guests too. Product data (titles, prices in the
|
||||
// customer's currency, availability, images) comes from Liquid's all_products / collections objects,
|
||||
// so it is always live and public. Styles and the add-to-cart script come from the app embed.
|
||||
import { num, offerSummary, moneyFormatter, pct } from "./offer-text";
|
||||
import type { OfferWithRelations } from "./offers.server";
|
||||
import { formatShopDate } from "./tz";
|
||||
|
||||
/** HTML-escape merchant text and neutralise Liquid delimiters so it can't run as Liquid. */
|
||||
export function esc(s: string | null | undefined) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\{/g, "{")
|
||||
.replace(/\}/g, "}")
|
||||
.replace(/%/g, "%");
|
||||
}
|
||||
|
||||
/** A string literal for a Liquid expression (handles are [a-z0-9-] but be strict anyway). */
|
||||
const lit = (s: string | null | undefined) => `'${String(s ?? "").replace(/[^a-zA-Z0-9_-]/g, "")}'`;
|
||||
|
||||
const MAX_CHOICES = 60;
|
||||
|
||||
interface ShopCtx { currency: string; timezone: string; branding: boolean }
|
||||
|
||||
function localized(o: OfferWithRelations, locale: string | null) {
|
||||
const i18n = (o.i18n || {}) as Record<string, { name?: string; badge?: string; description?: string }>;
|
||||
const t = locale ? i18n[locale] || i18n[locale.split("-")[0]] : undefined;
|
||||
return { name: t?.name || o.name, badge: t?.badge || o.badge || "", description: t?.description || o.description || "" };
|
||||
}
|
||||
|
||||
/** Summary with $-amounts marked for currency conversion by bundle-offers.js. */
|
||||
function summaryHtml(o: OfferWithRelations, currency: string) {
|
||||
const money = moneyFormatter(currency);
|
||||
const marker = "\u0000";
|
||||
const text = offerSummary(o, () => marker);
|
||||
const v = num(o.discountValue);
|
||||
return esc(text).replace(marker, `<span data-bo-money="${v}">${esc(money(v))}</span>`);
|
||||
}
|
||||
|
||||
function thumbs(o: OfferWithRelations) {
|
||||
return [...o.lines.map((l) => l.imageUrl), ...o.scopes.map((s) => s.imageUrl)].filter(Boolean).slice(0, 4) as string[];
|
||||
}
|
||||
|
||||
function media(o: OfferWithRelations) {
|
||||
if (o.imageUrl) return `<img src="${esc(o.imageUrl)}" alt="" width="800" height="600">`;
|
||||
const t = thumbs(o);
|
||||
if (!t.length) return "";
|
||||
return `<span class="bo-card__thumbs bo-card__thumbs--${t.length}">${t.map((src) => `<img src="${esc(src)}" alt="" loading="lazy" width="200" height="200">`).join("")}</span>`;
|
||||
}
|
||||
|
||||
/** Liquid that sums the live regular price (in cents) of a fixed bundle into `bo_regular`. */
|
||||
function fixedRegularLiquid(o: OfferWithRelations) {
|
||||
const parts = ["{%- assign bo_regular = 0 -%}"];
|
||||
for (const l of o.lines) {
|
||||
parts.push(`{%- assign bo_p = all_products[${lit(l.handle)}] -%}{%- assign bo_price = bo_p.price -%}`);
|
||||
if (l.variantId) {
|
||||
parts.push(`{%- for bo_v in bo_p.variants -%}{%- assign bo_vid = bo_v.id | append: '' -%}{%- if bo_vid == ${lit(l.variantId)} -%}{%- assign bo_price = bo_v.price -%}{%- endif -%}{%- endfor -%}`);
|
||||
}
|
||||
parts.push(`{%- assign bo_line = bo_price | times: ${l.quantity} -%}{%- assign bo_regular = bo_regular | plus: bo_line -%}`);
|
||||
}
|
||||
const dvCents = Math.round(num(o.discountValue) * 100);
|
||||
if (o.discountType === "percent") parts.push(`{%- assign bo_saving = bo_regular | times: ${num(o.discountValue)} | divided_by: 100.0 | round -%}`);
|
||||
else if (o.discountType === "amount") parts.push(`{%- assign bo_saving = ${dvCents} | at_most: bo_regular -%}`);
|
||||
else parts.push(`{%- assign bo_saving = bo_regular | minus: ${dvCents} | at_least: 0 -%}`);
|
||||
parts.push("{%- assign bo_bundle = bo_regular | minus: bo_saving -%}");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function card(o: OfferWithRelations, shop: ShopCtx, locale: string | null, base: string) {
|
||||
const t = localized(o, locale);
|
||||
const url = `${base}/${o.handle}`;
|
||||
const items = o.lines.reduce((s, l) => s + l.quantity, 0);
|
||||
const prices = o.type === "fixed" && o.lines.length
|
||||
? `${fixedRegularLiquid(o)}{%- if bo_regular > 0 -%}<p class="bo-card__prices" data-bo-fixed data-regular="{{ bo_regular }}" data-dt="${o.discountType}" data-dv="${num(o.discountValue)}">
|
||||
<span class="bo-card__now" data-bo-bundle>{{ bo_bundle | money }}</span>{%- if bo_saving > 0 -%} <s class="bo-card__was">{{ bo_regular | money }}</s>{%- endif -%}
|
||||
<span class="bo-card__count">${items} ${items === 1 ? "item" : "items"}</span></p>{%- endif -%}`
|
||||
: "";
|
||||
return `<article class="bo-card">
|
||||
<a class="bo-card__media" href="${url}" tabindex="-1" aria-hidden="true">${media(o)}</a>
|
||||
${t.badge ? `<p class="bo-stub"><span>${esc(t.badge)}</span></p>` : ""}
|
||||
<div class="bo-card__body">
|
||||
<h2 class="bo-card__title"><a href="${url}">${esc(t.name)}</a></h2>
|
||||
<p class="bo-card__deal">${summaryHtml(o, shop.currency)}</p>
|
||||
${prices}
|
||||
${o.endsAt ? `<p class="bo-card__ends">Ends ${esc(formatShopDate(o.endsAt, shop.timezone, false))}</p>` : ""}
|
||||
<a class="bo-button" href="${url}">Shop the bundle</a>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/** Proxy path: merchants can rename it, so use Shopify's path_prefix query parameter (default /apps/bundles). */
|
||||
export function proxyBase(pathPrefix: string | null | undefined) {
|
||||
const p = (pathPrefix || "/apps/bundles").replace(/[^a-zA-Z0-9/_-]/g, "").replace(/\/+$/, "");
|
||||
return p.startsWith("/") ? p : `/${p}`;
|
||||
}
|
||||
|
||||
export function bundlesListLiquid(offers: OfferWithRelations[], shop: ShopCtx, locale: string | null, base = "/apps/bundles") {
|
||||
const body = offers.length
|
||||
? `<div class="bo-grid">${offers.map((o) => card(o, shop, locale, base)).join("\n")}</div>`
|
||||
: `<div class="bo-empty"><p>No bundle offers right now. Check back for our festival specials!</p>
|
||||
<a class="bo-button" href="{{ routes.all_products_collection_url }}">Continue shopping</a></div>`;
|
||||
return `<section class="bo-section">
|
||||
<h1 class="bo-section__title">Bundle offers</h1>
|
||||
${body}
|
||||
${shop.branding ? `<p class="bo-powered">Bundles by <a href="https://apps.shopify.com/bundle-offers" rel="nofollow">Bundle Offers</a></p>` : ""}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// ---------- bundle page ----------
|
||||
|
||||
/** One selectable row. `pvar` is a Liquid product variable; `vexpr` the variant to add. */
|
||||
function choiceRow(pvar: string, vexpr: string, label: string, seenVar: string, alsoSkip?: string) {
|
||||
return `{%- if ${pvar}.id and ${pvar}.available -%}
|
||||
{%- assign bo_key = ',' | append: ${vexpr}.id | append: ',' -%}
|
||||
{%- unless ${seenVar} contains bo_key${alsoSkip ? ` or ${alsoSkip} contains bo_key` : ""} -%}{%- if bo_n < ${MAX_CHOICES} -%}{%- if ${vexpr}.available -%}
|
||||
{%- assign ${seenVar} = ${seenVar} | append: bo_key -%}{%- assign bo_n = bo_n | plus: 1 -%}
|
||||
<div class="bo-choice">
|
||||
{%- if ${pvar}.featured_image -%}{{ ${pvar}.featured_image | image_url: width: 128 | image_tag: alt: '', loading: 'lazy', width: 64, height: 64 }}{%- else -%}<span class="bo-choice__ph"></span>{%- endif -%}
|
||||
<div><a class="bo-choice__name" href="{{ ${pvar}.url }}">{{ ${pvar}.title }}${label}</a><span class="bo-choice__price">{{ ${vexpr}.price | money }}</span></div>
|
||||
<span class="bo-qty">
|
||||
<button type="button" data-step="-1" aria-label="Remove one">−</button>
|
||||
<input type="number" inputmode="numeric" min="0" max="99" value="0" data-variant="{{ ${vexpr}.id }}" aria-label="Quantity of {{ ${pvar}.title | escape }}">
|
||||
<button type="button" data-step="1" aria-label="Add one">+</button>
|
||||
</span>
|
||||
</div>
|
||||
{%- endif -%}{%- endif -%}{%- endunless -%}
|
||||
{%- endif -%}`;
|
||||
}
|
||||
|
||||
function scopeChoices(o: OfferWithRelations, role: string, seenVar: string, alsoSkip?: string) {
|
||||
const out: string[] = [];
|
||||
for (const s of o.scopes.filter((x) => x.role === role)) {
|
||||
if (s.kind === "collection") {
|
||||
out.push(`{%- for bo_cp in collections[${lit(s.handle)}].products limit: ${MAX_CHOICES} -%}${choiceRow("bo_cp", "bo_cp.selected_or_first_available_variant", "", seenVar, alsoSkip)}{%- endfor -%}`);
|
||||
} else if (s.kind === "product") {
|
||||
out.push(`{%- assign bo_pp = all_products[${lit(s.handle)}] -%}${choiceRow("bo_pp", "bo_pp.selected_or_first_available_variant", "", seenVar, alsoSkip)}`);
|
||||
} else {
|
||||
out.push(`{%- assign bo_pp = all_products[${lit(s.handle)}] -%}{%- for bo_vv in bo_pp.variants -%}{%- assign bo_vid = bo_vv.id | append: '' -%}{%- if bo_vid == ${lit(s.refId)} -%}${choiceRow("bo_pp", "bo_vv", " – {{ bo_vv.title }}", seenVar, alsoSkip)}{%- endif -%}{%- endfor -%}`);
|
||||
}
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function fixedRows(o: OfferWithRelations) {
|
||||
const rows: string[] = ["{%- assign bo_unavailable = false -%}"];
|
||||
for (const l of o.lines) {
|
||||
rows.push(`{%- assign bo_p = all_products[${lit(l.handle)}] -%}{%- assign bo_v = bo_p.selected_or_first_available_variant -%}`);
|
||||
if (l.variantId) rows.push(`{%- for bo_x in bo_p.variants -%}{%- assign bo_xid = bo_x.id | append: '' -%}{%- if bo_xid == ${lit(l.variantId)} -%}{%- assign bo_v = bo_x -%}{%- endif -%}{%- endfor -%}`);
|
||||
rows.push(`{%- if bo_p.id == nil or bo_v.available != true -%}{%- assign bo_unavailable = true -%}{%- endif -%}
|
||||
<div class="bo-choice{% if bo_v.available != true %} bo-choice--off{% endif %}">
|
||||
{%- if bo_p.featured_image -%}{{ bo_p.featured_image | image_url: width: 128 | image_tag: alt: '', loading: 'lazy', width: 64, height: 64 }}{%- else -%}<span class="bo-choice__ph"></span>{%- endif -%}
|
||||
<div><a class="bo-choice__name" href="{{ bo_p.url }}">{{ bo_p.title | default: '${esc(l.title)}' }}${l.variantId ? "{% unless bo_v.title == 'Default Title' %} – {{ bo_v.title }}{% endunless %}" : ""}</a></div>
|
||||
<span class="bo-choice__qty">${l.quantity} × {{ bo_v.price | money }}</span>
|
||||
<input type="hidden" data-variant="{{ bo_v.id }}" value="${l.quantity}">
|
||||
</div>`);
|
||||
}
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
export function bundlePageLiquid(o: OfferWithRelations, shop: ShopCtx, locale: string | null) {
|
||||
const t = localized(o, locale);
|
||||
const summary = summaryHtml(o, shop.currency);
|
||||
const hero = media(o);
|
||||
const ends = o.endsAt ? `<p class="bo-page__ends">Offer ends ${esc(formatShopDate(o.endsAt, shop.timezone))}</p>` : "";
|
||||
const needMore = `Please choose at least ${o.mixQty} items for this offer.`;
|
||||
const nothing = "Please choose at least one item.";
|
||||
|
||||
let right = "";
|
||||
if (o.type === "fixed") {
|
||||
right = `<form class="bo-form" data-bo-bundle-form data-type="fixed" data-nothing="${esc(nothing)}">
|
||||
<h2 class="bo-page__h">What's in the bundle</h2>
|
||||
${fixedRows(o)}
|
||||
${fixedRegularLiquid(o)}
|
||||
<dl class="bo-totals" data-bo-fixed data-regular="{{ bo_regular }}" data-dt="${o.discountType}" data-dv="${num(o.discountValue)}">
|
||||
<dt>Bought separately</dt><dd>{{ bo_regular | money }}</dd>
|
||||
<dt>Bundle price</dt><dd class="bo-totals__now" data-bo-bundle>{{ bo_bundle | money }}</dd>
|
||||
{%- if bo_saving > 0 -%}<dt>You save</dt><dd class="bo-totals__save" data-bo-save>{{ bo_saving | money }}</dd>{%- endif -%}
|
||||
</dl>
|
||||
{%- if bo_unavailable -%}<p class="bo-error">Some items in this bundle are out of stock.</p>{%- endif -%}
|
||||
<p class="bo-error" data-bo-error hidden></p>
|
||||
<button type="submit" class="bo-button bo-submit"{% if bo_unavailable %} disabled{% endif %}>Add bundle to cart</button>
|
||||
</form>`;
|
||||
} else if (o.type === "mix") {
|
||||
right = `<form class="bo-form" data-bo-bundle-form data-type="mix" data-need="${o.mixQty}" data-need-more="${esc(needMore)}" data-nothing="${esc(nothing)}">
|
||||
<h2 class="bo-page__h">Choose any ${o.mixQty}</h2>
|
||||
{%- assign bo_seen = ',' -%}{%- assign bo_n = 0 -%}
|
||||
${scopeChoices(o, "mix", "bo_seen")}
|
||||
<p class="bo-counter" data-bo-counter data-template="{k} of {n} chosen" aria-live="polite">0 of ${o.mixQty} chosen</p>
|
||||
<p class="bo-error" data-bo-error hidden></p>
|
||||
<button type="submit" class="bo-button bo-submit" disabled>Add to cart</button>
|
||||
</form>`;
|
||||
} else {
|
||||
const gp = num(o.getPercent);
|
||||
right = `<form class="bo-form" data-bo-bundle-form data-type="bxgy" data-nothing="${esc(nothing)}">
|
||||
<div class="bo-page__group">
|
||||
<h2 class="bo-page__h">Buy ${o.buyQty} of these</h2>
|
||||
{%- assign bo_buy = ',' -%}{%- assign bo_n = 0 -%}
|
||||
${scopeChoices(o, "buy", "bo_buy")}
|
||||
</div>
|
||||
<div class="bo-page__group">
|
||||
<h2 class="bo-page__h">${gp >= 100 ? `Get ${o.getQty} free` : `Get ${o.getQty} at ${pct(gp)}% off`}</h2>
|
||||
{%- assign bo_get = ',' -%}{%- assign bo_before = bo_n -%}
|
||||
${scopeChoices(o, "get", "bo_get", "bo_buy")}
|
||||
{%- if bo_n == bo_before -%}<p class="bo-page__note">From the products above.</p>{%- endif -%}
|
||||
</div>
|
||||
<p class="bo-counter" data-bo-counter data-template="{k} chosen" aria-live="polite">0 chosen</p>
|
||||
<p class="bo-error" data-bo-error hidden></p>
|
||||
<button type="submit" class="bo-button bo-submit" disabled>Add to cart</button>
|
||||
</form>`;
|
||||
}
|
||||
|
||||
return `<div class="bo-page">
|
||||
<div>
|
||||
<div class="bo-page__hero">${hero}${t.badge ? `<p class="bo-stub"><span>${esc(t.badge)}</span></p>` : ""}</div>
|
||||
<h1 class="bo-page__title">${esc(t.name)}</h1>
|
||||
<p class="bo-page__deal">${summary}</p>
|
||||
${t.description ? `<div class="bo-page__desc">${esc(t.description).replace(/\n/g, "<br>")}</div>` : ""}
|
||||
${ends}
|
||||
<p class="bo-page__note">The discount is applied automatically in your cart.</p>
|
||||
</div>
|
||||
<div>${right}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function notFoundLiquid(base = "/apps/bundles") {
|
||||
return `<section class="bo-section bo-empty">
|
||||
<h1 class="bo-section__title">This offer isn't available</h1>
|
||||
<p>It may have ended. See the bundle offers running now.</p>
|
||||
<a class="bo-button" href="${base}">See all bundles</a>
|
||||
</section>`;
|
||||
}
|
||||
87
app/lib/templates.ts
Normal file
87
app/lib/templates.ts
Normal file
@ -0,0 +1,87 @@
|
||||
// Festival templates and calendar (N03). Lunar-calendar dates are approximate: the UI asks the merchant to
|
||||
// check them against their local calendar before publishing.
|
||||
|
||||
export interface Festival {
|
||||
key: string;
|
||||
name: string;
|
||||
/** main day, YYYY-MM-DD */
|
||||
date: string;
|
||||
approximate: boolean;
|
||||
/** days the offer starts before / ends after the main day */
|
||||
leadDays: number;
|
||||
tailDays: number;
|
||||
}
|
||||
|
||||
export const FESTIVALS: Festival[] = [
|
||||
{ key: "navaratri-2026", name: "Navaratri", date: "2026-10-11", approximate: true, leadDays: 7, tailDays: 9 },
|
||||
{ key: "diwali-2026", name: "Diwali", date: "2026-11-08", approximate: true, leadDays: 21, tailDays: 1 },
|
||||
{ key: "black-friday-2026", name: "Black Friday", date: "2026-11-27", approximate: false, leadDays: 0, tailDays: 3 },
|
||||
{ key: "christmas-2026", name: "Christmas", date: "2026-12-25", approximate: false, leadDays: 21, tailDays: 1 },
|
||||
{ key: "pongal-2027", name: "Pongal / Makar Sankranti", date: "2027-01-14", approximate: true, leadDays: 10, tailDays: 3 },
|
||||
{ key: "lunar-new-year-2027", name: "Lunar New Year", date: "2027-02-06", approximate: true, leadDays: 14, tailDays: 7 },
|
||||
{ key: "eid-al-fitr-2027", name: "Eid al-Fitr", date: "2027-03-10", approximate: true, leadDays: 14, tailDays: 3 },
|
||||
{ key: "holi-2027", name: "Holi", date: "2027-03-22", approximate: true, leadDays: 10, tailDays: 1 },
|
||||
{ key: "eid-al-adha-2027", name: "Eid al-Adha", date: "2027-05-17", approximate: true, leadDays: 10, tailDays: 3 },
|
||||
{ key: "navaratri-2027", name: "Navaratri", date: "2027-09-30", approximate: true, leadDays: 7, tailDays: 9 },
|
||||
{ key: "diwali-2027", name: "Diwali", date: "2027-10-29", approximate: true, leadDays: 21, tailDays: 1 },
|
||||
];
|
||||
|
||||
export interface OfferTemplate {
|
||||
key: string;
|
||||
festival: string; // Festival.name or "Everyday"
|
||||
name: string;
|
||||
badge: string;
|
||||
description: string;
|
||||
type: "fixed" | "mix" | "bxgy";
|
||||
discountType: "percent" | "amount" | "fixed_price";
|
||||
discountValue: number;
|
||||
mixQty?: number;
|
||||
buyQty?: number;
|
||||
getQty?: number;
|
||||
getPercent?: number;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: OfferTemplate[] = [
|
||||
{ key: "diwali-puja-kit", festival: "Diwali", name: "Diwali Puja Kit", badge: "Diwali Special", type: "fixed", discountType: "percent", discountValue: 15,
|
||||
description: "Everything you need for Lakshmi Puja, in one bundle.", hint: "Add the diya, camphor, kumkum and incense products with their quantities." },
|
||||
{ key: "diwali-sweets", festival: "Diwali", name: "Any 3 Diwali Sweets", badge: "Festival offer", type: "mix", discountType: "fixed_price", discountValue: 20, mixQty: 3,
|
||||
description: "Mix and match your favourite mithai.", hint: "Add your sweets collection." },
|
||||
{ key: "navaratri-golu", festival: "Navaratri", name: "Navaratri Golu Set", badge: "Navaratri", type: "fixed", discountType: "amount", discountValue: 10,
|
||||
description: "A ready-made golu display set.", hint: "Add the dolls and steps that make up the set." },
|
||||
{ key: "garlands-b2g1", festival: "Navaratri", name: "Buy 2 Garlands, Get 1 Free", badge: "Festival offer", type: "bxgy", discountType: "percent", discountValue: 0, buyQty: 2, getQty: 1, getPercent: 100,
|
||||
description: "Fresh garlands for every day of the festival.", hint: "Use the same garland collection for Buy and Get." },
|
||||
{ key: "pongal-essentials", festival: "Pongal / Makar Sankranti", name: "Pongal Essentials", badge: "Pongal", type: "fixed", discountType: "percent", discountValue: 10,
|
||||
description: "Rice, jaggery, ghee and more for Pongal.", hint: "Add the essentials and quantities." },
|
||||
{ key: "eid-gift-box", festival: "Eid al-Fitr", name: "Eid Gift Box", badge: "Eid Mubarak", type: "mix", discountType: "percent", discountValue: 15, mixQty: 4,
|
||||
description: "Pick any 4 treats for a gift box.", hint: "Add dates, sweets and dry fruit collections." },
|
||||
{ key: "holi-colours", festival: "Holi", name: "Holi Colours Pack", badge: "Happy Holi", type: "mix", discountType: "fixed_price", discountValue: 15, mixQty: 5,
|
||||
description: "Any 5 colour packs.", hint: "Add your gulal / colour collection." },
|
||||
{ key: "lny-gift", festival: "Lunar New Year", name: "Lunar New Year Gift Set", badge: "New Year", type: "fixed", discountType: "percent", discountValue: 12,
|
||||
description: "A festive gift set.", hint: "Add the products of the gift set." },
|
||||
{ key: "christmas-hamper", festival: "Christmas", name: "Christmas Hamper", badge: "Christmas", type: "fixed", discountType: "fixed_price", discountValue: 49,
|
||||
description: "A hamper of seasonal favourites.", hint: "Add the hamper contents." },
|
||||
{ key: "black-friday-bogo", festival: "Black Friday", name: "Black Friday: Buy 1 Get 1 50% Off", badge: "Black Friday", type: "bxgy", discountType: "percent", discountValue: 0, buyQty: 1, getQty: 1, getPercent: 50,
|
||||
description: "Our biggest deal of the year.", hint: "Pick the collections that are part of the sale." },
|
||||
{ key: "everyday-mix", festival: "Everyday", name: "Any 3 for a bundle price", badge: "Bundle & Save", type: "mix", discountType: "fixed_price", discountValue: 25, mixQty: 3,
|
||||
description: "Mix and match any 3.", hint: "Add the products or collections that count." },
|
||||
];
|
||||
|
||||
export function festivalByName(name: string) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return FESTIVALS.filter((f) => f.name === name && f.date >= today).sort((a, b) => a.date.localeCompare(b.date))[0];
|
||||
}
|
||||
|
||||
/** Offer dates for a festival, as shop-local "YYYY-MM-DDTHH:mm" strings. */
|
||||
export function festivalWindow(f: Festival) {
|
||||
const day = new Date(`${f.date}T00:00:00Z`);
|
||||
const start = new Date(day.getTime() - f.leadDays * 86400000);
|
||||
const end = new Date(day.getTime() + f.tailDays * 86400000);
|
||||
return { start: `${start.toISOString().slice(0, 10)}T00:00`, end: `${end.toISOString().slice(0, 10)}T23:59` };
|
||||
}
|
||||
|
||||
export function upcomingFestivals(now = new Date(), days = 365) {
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const until = new Date(now.getTime() + days * 86400000).toISOString().slice(0, 10);
|
||||
return FESTIVALS.filter((f) => f.date >= today && f.date <= until).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
44
app/lib/tz.ts
Normal file
44
app/lib/tz.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// Offer dates are stored in UTC and edited in the shop's timezone (brief §3).
|
||||
|
||||
function offsetMinutes(timeZone: string, at: Date) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
}).formatToParts(at);
|
||||
const p = Object.fromEntries(parts.map((x) => [x.type, Number(x.value)]));
|
||||
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||
return Math.round((asUtc - at.getTime()) / 60000);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** "2026-10-20T00:00" in the shop timezone -> UTC ISO string (null for empty). */
|
||||
export function shopLocalToUtc(local: string | null | undefined, timeZone: string): string | null {
|
||||
if (!local) return null;
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(local);
|
||||
if (!m) return null;
|
||||
const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
|
||||
let t = guess - offsetMinutes(timeZone, new Date(guess)) * 60000;
|
||||
t = guess - offsetMinutes(timeZone, new Date(t)) * 60000; // second pass across DST changes
|
||||
return new Date(t).toISOString();
|
||||
}
|
||||
|
||||
/** UTC -> "YYYY-MM-DDTHH:mm" in the shop timezone, for <input type="datetime-local">. */
|
||||
export function utcToShopLocal(utc: Date | string | null | undefined, timeZone: string): string {
|
||||
if (!utc) return "";
|
||||
const d = new Date(utc);
|
||||
const local = new Date(d.getTime() + offsetMinutes(timeZone, d) * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function formatShopDate(utc: Date | string | null | undefined, timeZone: string, withTime = true) {
|
||||
if (!utc) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
timeZone, dateStyle: "medium", ...(withTime ? { timeStyle: "short" } : {}),
|
||||
}).format(new Date(utc));
|
||||
} catch {
|
||||
return new Date(utc).toISOString();
|
||||
}
|
||||
}
|
||||
57
app/lib/validation.ts
Normal file
57
app/lib/validation.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// Offer validation (F22). Same rules as the Odoo constraints; used by the editor (inline) and the server.
|
||||
import { num } from "./offer-text";
|
||||
|
||||
export interface OfferInput {
|
||||
name: string;
|
||||
badge?: string | null;
|
||||
description?: string | null;
|
||||
type: string;
|
||||
discountType: string;
|
||||
discountValue: number | string;
|
||||
mixQty: number;
|
||||
buyQty: number;
|
||||
getQty: number;
|
||||
getPercent: number | string;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
maxPerOrder: number;
|
||||
sequence: number;
|
||||
lines: { productId: string; variantId?: string | null; quantity: number }[];
|
||||
scopes: { role: string; kind: string; refId: string }[];
|
||||
}
|
||||
|
||||
export type Errors = Partial<Record<string, string>>;
|
||||
|
||||
export function validateOffer(o: OfferInput): Errors {
|
||||
const e: Errors = {};
|
||||
if (!o.name?.trim()) e.name = "Name is required.";
|
||||
else if (o.name.length > 80) e.name = "Use at most 80 characters (it is shown at checkout).";
|
||||
if (!["fixed", "mix", "bxgy"].includes(o.type)) e.type = "Choose a bundle type.";
|
||||
if (o.startsAt && o.endsAt && new Date(o.endsAt) < new Date(o.startsAt)) {
|
||||
e.endsAt = "The end date must be after the start date.";
|
||||
}
|
||||
if (!(o.maxPerOrder >= 0) || !Number.isInteger(o.maxPerOrder)) e.maxPerOrder = "Use 0 (no limit) or a whole number.";
|
||||
if (o.type === "fixed" || o.type === "mix") {
|
||||
if (!["percent", "amount", "fixed_price"].includes(o.discountType)) e.discountType = "Choose a discount type.";
|
||||
const v = num(o.discountValue);
|
||||
if (!(v > 0)) e.discountValue = "The discount value must be greater than 0.";
|
||||
else if (o.discountType === "percent" && v > 100) e.discountValue = "A percentage can't be more than 100.";
|
||||
}
|
||||
if (o.type === "fixed") {
|
||||
if (!o.lines.length) e.lines = "Add at least one product to the bundle.";
|
||||
else if (o.lines.some((l) => !(l.quantity >= 1) || !Number.isInteger(l.quantity))) e.lines = "Each quantity must be at least 1.";
|
||||
}
|
||||
if (o.type === "mix") {
|
||||
if (!o.scopes.some((s) => s.role === "mix")) e.scopes_mix = "Add products or collections.";
|
||||
if (!(o.mixQty >= 1)) e.mixQty = "The number of items must be at least 1.";
|
||||
}
|
||||
if (o.type === "bxgy") {
|
||||
if (!o.scopes.some((s) => s.role === "buy")) e.scopes_buy = "Add what the customer buys.";
|
||||
if (!o.scopes.some((s) => s.role === "get")) e.scopes_get = "Add what the customer gets.";
|
||||
if (!(o.buyQty >= 1)) e.buyQty = "The buy quantity must be at least 1.";
|
||||
if (!(o.getQty >= 1)) e.getQty = "The get quantity must be at least 1.";
|
||||
const gp = num(o.getPercent);
|
||||
if (!(gp > 0 && gp <= 100)) e.getPercent = "Use a percentage above 0 and up to 100 (100 = free).";
|
||||
}
|
||||
return e;
|
||||
}
|
||||
24
app/root.tsx
Normal file
24
app/root.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="preconnect" href="https://cdn.shopify.com/" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
|
||||
/>
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
3
app/routes.ts
Normal file
3
app/routes.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { flatRoutes } from "@react-router/fs-routes";
|
||||
|
||||
export default flatRoutes();
|
||||
57
app/routes/_index/route.tsx
Normal file
57
app/routes/_index/route.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect, Form, useLoaderData } from "react-router";
|
||||
|
||||
import { login } from "../../shopify.server";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.searchParams.get("shop")) {
|
||||
throw redirect(`/app?${url.searchParams.toString()}`);
|
||||
}
|
||||
|
||||
return { showForm: Boolean(login) };
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { showForm } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className={styles.index}>
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.heading}>Bundle Offers: run all your festival deals at once</h1>
|
||||
<p className={styles.text}>
|
||||
Fixed bundles, mix & match and buy X get Y, applied automatically at checkout. Each item counts towards one offer, in the order you choose.
|
||||
</p>
|
||||
{showForm && (
|
||||
<Form className={styles.form} method="post" action="/auth/login">
|
||||
<label className={styles.label}>
|
||||
<span>Shop domain</span>
|
||||
<input className={styles.input} type="text" name="shop" />
|
||||
<span>e.g: my-shop-domain.myshopify.com</span>
|
||||
</label>
|
||||
<button className={styles.button} type="submit">
|
||||
Log in
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
<strong>No discount stacking surprises</strong>. Many offers can run together; every item counts once,
|
||||
in your priority order.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Festival-ready</strong>. Templates and a calendar for Diwali, Eid, Pongal, Lunar New Year,
|
||||
Christmas and more, with scheduled start and end dates.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Native checkout</strong>. A Shopify discount function: works with Shop Pay and accelerated
|
||||
checkouts, with cart hints that nudge customers to complete a bundle.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
app/routes/_index/styles.module.css
Normal file
73
app/routes/_index/styles.module.css
Normal file
@ -0,0 +1,73 @@
|
||||
.index {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.text {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 1.2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: 0 auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
max-width: 20rem;
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
padding-top: 3rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.list > li {
|
||||
max-width: 20rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 50rem) {
|
||||
.list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.list > li {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
162
app/routes/app._index.tsx
Normal file
162
app/routes/app._index.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { ensureShop, gql } from "../lib/shop.server";
|
||||
import { currentMonthOrders, publishShop } from "../lib/publish.server";
|
||||
import { planOf } from "../lib/plans";
|
||||
import { moneyFormatter, offerStatus, offerSummary } from "../lib/offer-text";
|
||||
import { formatShopDate } from "../lib/tz";
|
||||
import { CONFIG_MAX_BYTES } from "../lib/compile";
|
||||
import { upcomingFestivals } from "../lib/templates";
|
||||
|
||||
function settingsMenuLinked(settings: unknown) {
|
||||
return !!(settings && typeof settings === "object" && (settings as { menuLinked?: boolean }).menuLinked);
|
||||
}
|
||||
|
||||
const THEME_QUERY = `#graphql
|
||||
query BundleOffersTheme {
|
||||
themes(first: 1, roles: [MAIN]) {
|
||||
nodes { id name files(filenames: ["config/settings_data.json"]) { nodes { body { ... on OnlineStoreThemeFileBodyText { content } } } } }
|
||||
}
|
||||
}`;
|
||||
|
||||
async function setupState(admin: Parameters<typeof gql>[0]) {
|
||||
let embedOn = false;
|
||||
let themeId = "";
|
||||
try {
|
||||
const t = await gql(admin, THEME_QUERY);
|
||||
const theme = t.themes.nodes[0];
|
||||
themeId = theme?.id?.split("/").pop() || "";
|
||||
const content: string = theme?.files?.nodes?.[0]?.body?.content || "";
|
||||
// app embeds are stored under current.blocks with type "shopify://apps/<app>/blocks/<handle>/<uuid>"
|
||||
const json = JSON.parse(content.replace(/\/\*[\s\S]*?\*\//g, "") || "{}");
|
||||
const blocks = json?.current?.blocks || {};
|
||||
embedOn = Object.values(blocks).some((b) => {
|
||||
const block = b as { type?: string; disabled?: boolean };
|
||||
return typeof block.type === "string" && block.type.includes("/blocks/bundle-offers-embed/") && !block.disabled;
|
||||
});
|
||||
} catch {
|
||||
/* read_themes missing or theme unreadable: show the step as to-do */
|
||||
}
|
||||
return { embedOn, themeId };
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop, true);
|
||||
if (!shop.lastPublishedAt) await publishShop(admin, session.shop);
|
||||
const plan = planOf(shop.planCode);
|
||||
const now = new Date();
|
||||
const offers = await db.offer.findMany({ where: { shopId: shop.id, active: true }, orderBy: [{ sequence: "asc" }] });
|
||||
const running = offers.filter((o) => offerStatus(o, now) === "running");
|
||||
const since = new Date(now.getTime() - 30 * 86400000);
|
||||
const usage = await db.orderUsage.aggregate({ where: { shopId: shop.id, createdAt: { gte: since } }, _sum: { discount: true, bundleRevenue: true } });
|
||||
const orders = await db.orderUsage.groupBy({ by: ["orderId"], where: { shopId: shop.id, createdAt: { gte: since } } });
|
||||
const setup = await setupState(admin);
|
||||
const money = moneyFormatter(shop.currency);
|
||||
const apiKey = process.env.SHOPIFY_API_KEY || "";
|
||||
return {
|
||||
shop: { domain: shop.domain, publishError: shop.publishError, configBytes: shop.configBytes, monthOrders: currentMonthOrders(shop) },
|
||||
plan,
|
||||
running: running.map((o) => ({ id: o.id, name: o.name, summary: offerSummary(o, money), ends: formatShopDate(o.endsAt, shop.timezone) })),
|
||||
totalOffers: offers.length,
|
||||
stats: {
|
||||
orders: orders.length,
|
||||
revenue: money(Number(usage._sum.bundleRevenue || 0)),
|
||||
discount: money(Number(usage._sum.discount || 0)),
|
||||
},
|
||||
setup,
|
||||
menuLinked: settingsMenuLinked(shop.settings),
|
||||
embedLink: `https://${shop.domain}/admin/themes/${setup.themeId || "current"}/editor?context=apps&activateAppId=${apiKey}/bundle-offers-embed`,
|
||||
festivals: upcomingFestivals(now, 120).slice(0, 4),
|
||||
configMax: CONFIG_MAX_BYTES,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const d = useLoaderData<typeof loader>();
|
||||
const steps = [
|
||||
{ done: d.setup.embedOn, label: "Turn on the Bundle Offers app embed", help: "Loads the storefront script and styles (cart hints, add bundle to cart).", action: <s-button href={d.embedLink} target="_blank">Open theme editor</s-button> },
|
||||
{ done: d.menuLinked, label: "Link the bundles page in your menu", help: "Your bundles page is at /apps/bundles. Add it to the main menu (Online Store › Navigation), or place the “Bundles grid” block on any page.", action: <s-button href={`https://${d.shop.domain}/admin/menus`} target="_blank">Open navigation</s-button> },
|
||||
{ done: d.totalOffers > 0, label: "Create your first offer", help: "Start from scratch or from a festival template.", action: <s-button href="/app/offers/new">Create offer</s-button> },
|
||||
{ done: d.running.length > 0, label: "Publish an offer and preview it", help: "Published offers within their dates run automatically in cart and checkout.", action: <s-button href={`https://${d.shop.domain}/apps/bundles`} target="_blank">View bundles page</s-button> },
|
||||
];
|
||||
const doneCount = steps.filter((s) => s.done).length;
|
||||
|
||||
return (
|
||||
<s-page heading="Bundle Offers">
|
||||
<s-button slot="primary-action" variant="primary" href="/app/offers/new">Create offer</s-button>
|
||||
|
||||
{d.shop.publishError ? (
|
||||
<s-banner tone="critical" heading="Your offers could not be published"><s-paragraph>{d.shop.publishError}</s-paragraph></s-banner>
|
||||
) : null}
|
||||
{d.plan.monthlyOrders !== Infinity && d.shop.monthOrders >= d.plan.monthlyOrders ? (
|
||||
<s-banner tone="warning" heading={`You've reached ${d.plan.monthlyOrders} bundle orders this month`}>
|
||||
<s-paragraph>Bundle discounts are paused until next month. Upgrade to keep them running.</s-paragraph>
|
||||
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
|
||||
</s-banner>
|
||||
) : null}
|
||||
|
||||
{doneCount < steps.length ? (
|
||||
<s-section heading={`Setup: ${doneCount} of ${steps.length} done`}>
|
||||
<s-stack gap="base">
|
||||
{steps.map((s) => (
|
||||
<s-stack key={s.label} direction="inline" gap="base" alignItems="center" justifyContent="space-between">
|
||||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||||
<s-icon type={s.done ? "check-circle" : "circle"} tone={s.done ? "success" : "neutral"} />
|
||||
<s-stack gap="small-500">
|
||||
<s-text type="strong">{s.label}</s-text>
|
||||
<s-text color="subdued">{s.help}</s-text>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
{s.done ? null : s.action}
|
||||
</s-stack>
|
||||
))}
|
||||
</s-stack>
|
||||
</s-section>
|
||||
) : null}
|
||||
|
||||
<s-section heading="Last 30 days">
|
||||
<s-grid gridTemplateColumns="1fr 1fr 1fr" gap="base">
|
||||
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Bundle orders</s-text><s-heading>{String(d.stats.orders)}</s-heading></s-stack></s-box>
|
||||
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Bundle revenue</s-text><s-heading>{d.stats.revenue}</s-heading></s-stack></s-box>
|
||||
<s-box padding="base" background="subdued" borderRadius="base"><s-stack gap="small-500"><s-text color="subdued">Discounts given</s-text><s-heading>{d.stats.discount}</s-heading></s-stack></s-box>
|
||||
</s-grid>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Running now">
|
||||
{d.running.length ? (
|
||||
<s-stack gap="small-200">
|
||||
{d.running.map((o) => (
|
||||
<s-stack key={o.id} direction="inline" justifyContent="space-between">
|
||||
<s-link href={`/app/offers/${o.id}`}>{o.name}</s-link>
|
||||
<s-text color="subdued">{o.summary}{o.ends ? ` · ends ${o.ends}` : ""}</s-text>
|
||||
</s-stack>
|
||||
))}
|
||||
</s-stack>
|
||||
) : <s-paragraph>No offer is running. Published offers within their dates run automatically.</s-paragraph>}
|
||||
</s-section>
|
||||
|
||||
<s-section slot="aside" heading="Plan">
|
||||
<s-stack gap="small-200">
|
||||
<s-text type="strong">{d.plan.name}</s-text>
|
||||
<s-text>Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers}</s-text>
|
||||
{d.plan.monthlyOrders !== Infinity ? <s-text>Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders}</s-text> : null}
|
||||
<s-text color="subdued">Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit</s-text>
|
||||
<s-button href="/app/plans">Plans</s-button>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section slot="aside" heading="Coming up">
|
||||
<s-stack gap="small-200">
|
||||
{d.festivals.map((f) => <s-text key={f.key}>{f.name}: {f.date}{f.approximate ? " (approx.)" : ""}</s-text>)}
|
||||
<s-button href="/app/templates">Festival templates</s-button>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
152
app/routes/app.analytics.tsx
Normal file
152
app/routes/app.analytics.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { Form, useLoaderData } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { ensureShop } from "../lib/shop.server";
|
||||
import { planOf } from "../lib/plans";
|
||||
import { moneyFormatter } from "../lib/offer-text";
|
||||
import { formatShopDate } from "../lib/tz";
|
||||
|
||||
const RANGES: Record<string, number> = { "7": 7, "30": 30, "90": 90, "365": 365 };
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const url = new URL(request.url);
|
||||
const days = RANGES[url.searchParams.get("days") || "30"] || 30;
|
||||
const offerFilter = url.searchParams.get("offer") || "";
|
||||
const since = new Date(Date.now() - days * 86400000);
|
||||
const money = moneyFormatter(shop.currency);
|
||||
|
||||
const usages = await db.orderUsage.findMany({ where: { shopId: shop.id, createdAt: { gte: since } }, orderBy: { createdAt: "desc" } });
|
||||
const stats = await db.orderStat.findMany({ where: { shopId: shop.id, createdAt: { gte: since } } });
|
||||
const byOffer = new Map<string, { name: string; orders: Set<string>; revenue: number; discount: number }>();
|
||||
for (const u of usages) {
|
||||
const e = byOffer.get(u.offerName) || { name: u.offerName, orders: new Set<string>(), revenue: 0, discount: 0 };
|
||||
e.orders.add(u.orderId);
|
||||
e.revenue += Number(u.bundleRevenue);
|
||||
e.discount += Number(u.discount);
|
||||
byOffer.set(u.offerName, e);
|
||||
}
|
||||
const avg = (xs: number[]) => (xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0);
|
||||
const withB = stats.filter((s) => s.hasBundle).map((s) => Number(s.total));
|
||||
const without = stats.filter((s) => !s.hasBundle).map((s) => Number(s.total));
|
||||
const offers = [...byOffer.values()].sort((a, b) => b.revenue - a.revenue);
|
||||
const recent = usages.filter((u) => !offerFilter || u.offerName === offerFilter).slice(0, 50);
|
||||
|
||||
return {
|
||||
allowed: planOf(shop.planCode).analytics,
|
||||
days: String(days),
|
||||
offerFilter,
|
||||
totals: {
|
||||
orders: new Set(usages.map((u) => u.orderId)).size,
|
||||
revenue: money(offers.reduce((s, o) => s + o.revenue, 0)),
|
||||
discount: money(offers.reduce((s, o) => s + o.discount, 0)),
|
||||
aovWith: money(avg(withB)),
|
||||
aovWithout: money(avg(without)),
|
||||
lift: avg(without) ? `${Math.round(((avg(withB) - avg(without)) / avg(without)) * 100)}%` : "—",
|
||||
},
|
||||
offers: offers.map((o) => ({ name: o.name, orders: o.orders.size, revenue: money(o.revenue), discount: money(o.discount) })),
|
||||
recent: recent.map((u) => ({
|
||||
id: u.id, orderId: u.orderId, orderName: u.orderName || u.orderId, offer: u.offerName,
|
||||
discount: money(Number(u.discount)), at: formatShopDate(u.createdAt, shop.timezone),
|
||||
})),
|
||||
shopDomain: shop.domain,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Analytics() {
|
||||
const d = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<s-page heading="Analytics" inlineSize="large">
|
||||
{!d.allowed ? (
|
||||
<s-banner tone="info" heading="Full analytics are on the Growth plan">
|
||||
<s-paragraph>You can still see orders per offer below.</s-paragraph>
|
||||
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
|
||||
</s-banner>
|
||||
) : null}
|
||||
<s-section padding="base">
|
||||
<Form method="get">
|
||||
<s-stack direction="inline" gap="base" alignItems="end">
|
||||
<s-select label="Date range" name="days" value={d.days}>
|
||||
<s-option value="7">Last 7 days</s-option>
|
||||
<s-option value="30">Last 30 days</s-option>
|
||||
<s-option value="90">Last 90 days</s-option>
|
||||
<s-option value="365">Last 12 months</s-option>
|
||||
</s-select>
|
||||
{d.offerFilter ? <input type="hidden" name="offer" value={d.offerFilter} /> : null}
|
||||
<s-button type="submit">Apply</s-button>
|
||||
</s-stack>
|
||||
</Form>
|
||||
</s-section>
|
||||
|
||||
{d.allowed ? (
|
||||
<s-section heading="Overview">
|
||||
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap="base">
|
||||
{[
|
||||
["Bundle orders", String(d.totals.orders)],
|
||||
["Bundle revenue", d.totals.revenue],
|
||||
["Discounts given", d.totals.discount],
|
||||
["AOV with a bundle", d.totals.aovWith],
|
||||
["AOV without", d.totals.aovWithout],
|
||||
["AOV lift", d.totals.lift],
|
||||
].map(([label, value]) => (
|
||||
<s-box key={label} padding="base" background="subdued" borderRadius="base">
|
||||
<s-stack gap="small-500"><s-text color="subdued">{label}</s-text><s-heading>{value}</s-heading></s-stack>
|
||||
</s-box>
|
||||
))}
|
||||
</s-grid>
|
||||
</s-section>
|
||||
) : null}
|
||||
|
||||
<s-section heading="By offer">
|
||||
{d.offers.length ? (
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Offer</s-table-header>
|
||||
<s-table-header format="numeric">Orders</s-table-header>
|
||||
{d.allowed ? <s-table-header format="numeric">Bundle revenue</s-table-header> : null}
|
||||
{d.allowed ? <s-table-header format="numeric">Discount</s-table-header> : null}
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{d.offers.map((o) => (
|
||||
<s-table-row key={o.name}>
|
||||
<s-table-cell><s-link href={`/app/analytics?days=${d.days}&offer=${encodeURIComponent(o.name)}`}>{o.name}</s-link></s-table-cell>
|
||||
<s-table-cell>{o.orders}</s-table-cell>
|
||||
{d.allowed ? <s-table-cell>{o.revenue}</s-table-cell> : null}
|
||||
{d.allowed ? <s-table-cell>{o.discount}</s-table-cell> : null}
|
||||
</s-table-row>
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
) : <s-paragraph>No bundle orders in this period yet.</s-paragraph>}
|
||||
</s-section>
|
||||
|
||||
<s-section heading={d.offerFilter ? `Orders with “${d.offerFilter}”` : "Recent bundle orders"}>
|
||||
{d.recent.length ? (
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Order</s-table-header>
|
||||
<s-table-header>Offer</s-table-header>
|
||||
<s-table-header format="numeric">Discount</s-table-header>
|
||||
<s-table-header>Date</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{d.recent.map((r) => (
|
||||
<s-table-row key={r.id}>
|
||||
<s-table-cell><s-link href={`shopify://admin/orders/${r.orderId}`} target="_top">{r.orderName}</s-link></s-table-cell>
|
||||
<s-table-cell>{r.offer}</s-table-cell>
|
||||
<s-table-cell>{r.discount}</s-table-cell>
|
||||
<s-table-cell>{r.at}</s-table-cell>
|
||||
</s-table-row>
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
) : <s-paragraph>No orders.</s-paragraph>}
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
475
app/routes/app.offers.$id.tsx
Normal file
475
app/routes/app.offers.$id.tsx
Normal file
@ -0,0 +1,475 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { redirect, useActionData, useFetcher, useLoaderData, useNavigation, useSubmit } from "react-router";
|
||||
import { useAppBridge } from "@shopify/app-bridge-react";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { ensureShop } from "../lib/shop.server";
|
||||
import { duplicateOffer, offerInclude, saveOffer, type OfferForm, type OfferFormLine, type OfferFormScope } from "../lib/offers.server";
|
||||
import { publishShop } from "../lib/publish.server";
|
||||
import { moneyFormatter, offerStatus, offerSummary, STATUS_LABEL, STATUS_TONE } from "../lib/offer-text";
|
||||
import { validateOffer, type Errors } from "../lib/validation";
|
||||
import { planOf } from "../lib/plans";
|
||||
import { festivalByName, festivalWindow, TEMPLATES } from "../lib/templates";
|
||||
import { formatShopDate, shopLocalToUtc, utcToShopLocal } from "../lib/tz";
|
||||
import { allocateByLine, applyOffers, toUnits } from "../../engine/engine.js";
|
||||
|
||||
type FormState = Omit<OfferForm, "startsAt" | "endsAt" | "discountValue" | "getPercent"> & {
|
||||
startsAt: string; // shop-local datetime-local value
|
||||
endsAt: string;
|
||||
discountValue: string;
|
||||
getPercent: string;
|
||||
i18n: Record<string, { name?: string; badge?: string; description?: string }>;
|
||||
};
|
||||
|
||||
const blank: FormState = {
|
||||
name: "", badge: "", description: "", type: "fixed", discountType: "percent", discountValue: "10",
|
||||
mixQty: 3, buyQty: 2, getQty: 1, getPercent: "100", startsAt: "", endsAt: "", maxPerOrder: 0, sequence: 10,
|
||||
published: false, countries: [], showHome: true, showProduct: true, showCartHints: true, imageUrl: "",
|
||||
lines: [], scopes: [], i18n: {},
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const isNew = params.id === "new";
|
||||
let form: FormState = { ...blank };
|
||||
let meta: { status: string; orders: number; handle: string; active: boolean; createdAt: string | null } = {
|
||||
status: "draft", orders: 0, handle: "", active: true, createdAt: null,
|
||||
};
|
||||
let history: { field: string; oldValue: string | null; newValue: string | null; staffUser: string | null; at: string }[] = [];
|
||||
let templateHint = "";
|
||||
|
||||
if (!isNew) {
|
||||
const o = await db.offer.findFirst({ where: { id: params.id, shopId: shop.id }, include: offerInclude });
|
||||
if (!o) throw new Response("Offer not found", { status: 404 });
|
||||
form = {
|
||||
name: o.name, badge: o.badge || "", description: o.description || "", type: o.type, discountType: o.discountType,
|
||||
discountValue: String(Number(o.discountValue)), mixQty: o.mixQty, buyQty: o.buyQty, getQty: o.getQty,
|
||||
getPercent: String(Number(o.getPercent)), startsAt: utcToShopLocal(o.startsAt, shop.timezone), endsAt: utcToShopLocal(o.endsAt, shop.timezone),
|
||||
maxPerOrder: o.maxPerOrder, sequence: o.sequence, published: o.published, active: o.active, countries: o.countries,
|
||||
showHome: o.showHome, showProduct: o.showProduct, showCartHints: o.showCartHints, imageUrl: o.imageUrl || "",
|
||||
lines: o.lines.map((l) => ({ productId: l.productId, variantId: l.variantId, quantity: l.quantity, title: l.title, handle: l.handle, imageUrl: l.imageUrl })),
|
||||
scopes: o.scopes.map((s) => ({ role: s.role, kind: s.kind, refId: s.refId, parentId: s.parentId, title: s.title, handle: s.handle, imageUrl: s.imageUrl })),
|
||||
i18n: (o.i18n || {}) as FormState["i18n"],
|
||||
};
|
||||
meta = {
|
||||
status: o.active ? offerStatus(o) : "archived",
|
||||
orders: await db.orderUsage.count({ where: { offerId: o.id } }),
|
||||
handle: o.handle, active: o.active, createdAt: o.createdAt.toISOString(),
|
||||
};
|
||||
history = (await db.auditLog.findMany({ where: { offerId: o.id }, orderBy: { at: "desc" }, take: 25 })).map((h) => ({
|
||||
field: h.field, oldValue: h.oldValue, newValue: h.newValue, staffUser: h.staffUser, at: formatShopDate(h.at, shop.timezone),
|
||||
}));
|
||||
} else {
|
||||
const key = new URL(request.url).searchParams.get("template");
|
||||
const t = TEMPLATES.find((x) => x.key === key);
|
||||
if (t) {
|
||||
const fest = festivalByName(t.festival);
|
||||
const win = fest ? festivalWindow(fest) : null;
|
||||
form = {
|
||||
...blank, name: t.name, badge: t.badge, description: t.description, type: t.type, discountType: t.discountType,
|
||||
discountValue: String(t.discountValue || 10), mixQty: t.mixQty ?? 3, buyQty: t.buyQty ?? 2, getQty: t.getQty ?? 1,
|
||||
getPercent: String(t.getPercent ?? 100), startsAt: win?.start || "", endsAt: win?.end || "",
|
||||
};
|
||||
templateHint = `${t.hint}${fest?.approximate ? ` ${fest.name} dates are approximate: check them against your local calendar.` : ""}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
isNew, form, meta, history, templateHint,
|
||||
shop: { domain: shop.domain, currency: shop.currency, timezone: shop.timezone },
|
||||
plan: planOf(shop.planCode),
|
||||
};
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const fd = await request.formData();
|
||||
const intent = String(fd.get("intent") || "save");
|
||||
const staff = session.onlineAccessInfo?.associated_user?.email;
|
||||
const id = params.id === "new" ? null : params.id!;
|
||||
|
||||
if (intent === "duplicate" && id) {
|
||||
const copy = await duplicateOffer(shop.id, id, staff);
|
||||
await publishShop(admin, session.shop);
|
||||
return redirect(`/app/offers/${copy!.id}?saved=duplicated`);
|
||||
}
|
||||
if ((intent === "archive" || intent === "restore") && id) {
|
||||
await db.offer.update({ where: { id }, data: intent === "archive" ? { active: false, published: false } : { active: true } });
|
||||
await db.auditLog.create({ data: { shopId: shop.id, offerId: id, staffUser: staff, field: intent, newValue: "" } });
|
||||
await publishShop(admin, session.shop);
|
||||
return redirect(`/app/offers/${id}?saved=${intent}`);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(String(fd.get("payload"))) as FormState;
|
||||
const form: OfferForm = {
|
||||
...raw,
|
||||
startsAt: shopLocalToUtc(raw.startsAt, shop.timezone),
|
||||
endsAt: shopLocalToUtc(raw.endsAt, shop.timezone),
|
||||
};
|
||||
const plan = planOf(shop.planCode);
|
||||
const errors: Errors = {};
|
||||
if (form.published && !plan.types.includes(form.type)) errors.type = `${form.type === "mix" ? "Mix & match" : "Buy X get Y"} bundles need a paid plan. Save as a draft or upgrade.`;
|
||||
if (form.published && !plan.scheduling && (form.startsAt || form.endsAt)) errors.startsAt = "Start and end dates need a paid plan.";
|
||||
if (form.countries.length && !plan.markets) errors.countries = "Market targeting needs the Growth plan.";
|
||||
if (Object.keys(errors).length) return { errors, publishError: null };
|
||||
|
||||
const saved = await saveOffer(shop.id, id, form, staff);
|
||||
if (saved.errors) return { errors: saved.errors, publishError: null };
|
||||
const outcome = await publishShop(admin, session.shop);
|
||||
if (!outcome.ok) return { errors: {}, publishError: outcome.error, savedId: saved.offer!.id };
|
||||
const reason = outcome.result.excluded[saved.offer!.id];
|
||||
return redirect(`/app/offers/${saved.offer!.id}?saved=1${reason ? `&limited=${encodeURIComponent(reason)}` : ""}`);
|
||||
};
|
||||
|
||||
// ---------- UI ----------
|
||||
|
||||
type Picked = { id: string; title?: string; handle?: string; images?: { originalSrc?: string; url?: string }[]; image?: { originalSrc?: string; url?: string } | null; totalVariants?: number; variants?: { id: string; title?: string; price?: string }[] };
|
||||
const nid = (gid: string) => gid.split("/").pop() || gid;
|
||||
const img = (p: Picked) => p.images?.[0]?.originalSrc || p.images?.[0]?.url || p.image?.originalSrc || p.image?.url || null;
|
||||
|
||||
export default function OfferEditor() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const shopify = useAppBridge();
|
||||
const submit = useSubmit();
|
||||
const nav = useNavigation();
|
||||
const upload = useFetcher<{ url?: string; error?: string }>();
|
||||
const [f, setF] = useState<FormState>(data.form);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
const [locale, setLocale] = useState("fr");
|
||||
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||
const saving = nav.state === "submitting";
|
||||
const money = useMemo(() => moneyFormatter(data.shop.currency), [data.shop.currency]);
|
||||
|
||||
useEffect(() => setF(data.form), [data.form]);
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
if (p.get("saved")) shopify.toast.show(p.get("saved") === "duplicated" ? "Offer duplicated" : "Offer saved");
|
||||
if (p.get("limited")) shopify.toast.show(`Saved, but not running: ${p.get("limited")}`, { isError: true, duration: 8000 });
|
||||
}, [data, shopify]);
|
||||
useEffect(() => {
|
||||
if (upload.data?.url) setF((s) => ({ ...s, imageUrl: upload.data!.url! }));
|
||||
if (upload.data?.error) shopify.toast.show(upload.data.error, { isError: true });
|
||||
}, [upload.data, shopify]);
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => setF((s) => ({ ...s, [k]: v }));
|
||||
const clientErrors = validateOffer({ ...f, startsAt: f.startsAt || null, endsAt: f.endsAt || null });
|
||||
const serverErrors = (actionData && "errors" in actionData ? actionData.errors : {}) || {};
|
||||
const errors: Errors = { ...(showErrors ? clientErrors : {}), ...serverErrors };
|
||||
const err = (k: string) => errors[k] || undefined;
|
||||
|
||||
const summary = offerSummary({ ...f, active: true, startsAt: null, endsAt: null }, money);
|
||||
|
||||
const save = (publish?: boolean) => {
|
||||
setShowErrors(true);
|
||||
const next = publish === undefined ? f : { ...f, published: publish };
|
||||
if (Object.keys(validateOffer({ ...next, startsAt: next.startsAt || null, endsAt: next.endsAt || null })).length) {
|
||||
shopify.toast.show("Please fix the highlighted fields", { isError: true });
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.set("intent", "save");
|
||||
fd.set("payload", JSON.stringify(next));
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
const simple = (intent: string) => {
|
||||
const fd = new FormData();
|
||||
fd.set("intent", intent);
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
|
||||
// ---- pickers ----
|
||||
async function pickLines() {
|
||||
const selected = (await shopify.resourcePicker({ type: "product", multiple: true, filter: { variants: true } })) as Picked[] | undefined;
|
||||
if (!selected) return;
|
||||
const add: OfferFormLine[] = [];
|
||||
for (const p of selected) {
|
||||
const variants = p.variants || [];
|
||||
const whole = !variants.length || (p.totalVariants ?? variants.length) <= variants.length;
|
||||
if (whole) add.push({ productId: nid(p.id), variantId: null, quantity: 1, title: p.title, handle: p.handle, imageUrl: img(p) });
|
||||
else for (const v of variants) add.push({ productId: nid(p.id), variantId: nid(v.id), quantity: 1, title: `${p.title} – ${v.title}`, handle: p.handle, imageUrl: img(p) });
|
||||
const price = variants[0]?.price;
|
||||
if (price) setPrices((s) => ({ ...s, [nid(p.id)]: price }));
|
||||
}
|
||||
setF((s) => ({ ...s, lines: [...s.lines, ...add.filter((a) => !s.lines.some((l) => l.productId === a.productId && (l.variantId || null) === (a.variantId || null)))] }));
|
||||
}
|
||||
|
||||
async function pickScope(role: string, kind: "product" | "collection") {
|
||||
const selected = (await shopify.resourcePicker({ type: kind, multiple: true, ...(kind === "product" ? { filter: { variants: true } } : {}) })) as Picked[] | undefined;
|
||||
if (!selected) return;
|
||||
const add: OfferFormScope[] = [];
|
||||
for (const p of selected) {
|
||||
if (kind === "collection") { add.push({ role, kind: "collection", refId: nid(p.id), title: p.title, handle: p.handle, imageUrl: img(p) }); continue; }
|
||||
const variants = p.variants || [];
|
||||
const whole = !variants.length || (p.totalVariants ?? variants.length) <= variants.length;
|
||||
if (whole) add.push({ role, kind: "product", refId: nid(p.id), title: p.title, handle: p.handle, imageUrl: img(p) });
|
||||
else for (const v of variants) add.push({ role, kind: "variant", refId: nid(v.id), parentId: nid(p.id), title: `${p.title} – ${v.title}`, handle: p.handle, imageUrl: img(p) });
|
||||
const price = variants[0]?.price;
|
||||
if (price) setPrices((s) => ({ ...s, [nid(p.id)]: price }));
|
||||
}
|
||||
setF((s) => ({ ...s, scopes: [...s.scopes, ...add.filter((a) => !s.scopes.some((x) => x.role === a.role && x.kind === a.kind && x.refId === a.refId))] }));
|
||||
}
|
||||
|
||||
const removeScope = (i: number) => setF((s) => ({ ...s, scopes: s.scopes.filter((_, k) => k !== i) }));
|
||||
|
||||
const ScopeList = ({ role, title, help, error }: { role: string; title: string; help: string; error?: string }) => (
|
||||
<s-stack gap="small-200">
|
||||
<s-heading>{title}</s-heading>
|
||||
<s-text color="subdued">{help}</s-text>
|
||||
{error ? <s-text tone="critical">{error}</s-text> : null}
|
||||
{f.scopes.map((s, i) => (s.role !== role ? null : (
|
||||
<s-stack key={`${s.kind}${s.refId}`} direction="inline" gap="base" alignItems="center">
|
||||
<s-thumbnail src={s.imageUrl || undefined} alt="" size="small-200" />
|
||||
<s-text>{s.title || s.refId}</s-text>
|
||||
<s-badge>{s.kind === "collection" ? "Collection" : s.kind === "variant" ? "Variant" : "Product"}</s-badge>
|
||||
<s-button variant="tertiary" icon="delete" accessibilityLabel={`Remove ${s.title}`} onClick={() => removeScope(i)} />
|
||||
</s-stack>
|
||||
)))}
|
||||
<s-stack direction="inline" gap="small-200">
|
||||
<s-button onClick={() => pickScope(role, "product")}>Add products</s-button>
|
||||
<s-button onClick={() => pickScope(role, "collection")}>Add collections</s-button>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
);
|
||||
|
||||
// ---- example cart preview: the real engine on sample quantities ----
|
||||
const preview = useMemo(() => {
|
||||
const items = new Map<string, { id: string; label: string; variantId: string; productId: string; collectionIds: string[]; qty: number }>();
|
||||
if (f.type === "fixed") for (const l of f.lines) items.set(`p${l.productId}v${l.variantId || ""}`, { id: `p${l.productId}v${l.variantId || ""}`, label: l.title || l.productId, variantId: l.variantId || `any-${l.productId}`, productId: l.productId, collectionIds: [], qty: l.quantity });
|
||||
else for (const s of f.scopes) {
|
||||
const key = `${s.kind}${s.refId}`;
|
||||
if (items.has(key)) continue;
|
||||
const qty = f.type === "mix" ? 1 : s.role === "buy" ? f.buyQty : f.getQty;
|
||||
items.set(key, {
|
||||
id: key, label: s.title || s.refId, qty,
|
||||
variantId: s.kind === "variant" ? s.refId : `v-${key}`, productId: s.kind === "product" ? s.refId : s.parentId || `p-${key}`,
|
||||
collectionIds: s.kind === "collection" ? [s.refId] : [],
|
||||
});
|
||||
}
|
||||
const rows = [...items.values()];
|
||||
if (f.type === "mix" && rows.length) rows[0].qty = Math.max(rows[0].qty, f.mixQty - rows.length + 1);
|
||||
const price = (r: (typeof rows)[number]) => Number(prices[r.productId] || prices[r.id] || 10) || 10;
|
||||
const units = toUnits(rows.map((r) => ({ id: r.id, variantId: r.variantId, productId: r.productId, collectionIds: r.collectionIds, unitPrice: price(r), quantity: r.qty })));
|
||||
const scope = (role: string) => {
|
||||
const sc = f.scopes.filter((s) => s.role === role);
|
||||
return {
|
||||
variantIds: new Set(sc.filter((s) => s.kind === "variant").map((s) => s.refId)),
|
||||
productIds: new Set(sc.filter((s) => s.kind === "product").map((s) => s.refId)),
|
||||
collectionIds: new Set(sc.filter((s) => s.kind === "collection").map((s) => s.refId)),
|
||||
};
|
||||
};
|
||||
const offer = {
|
||||
id: "x", type: f.type, maxUses: f.maxPerOrder, discountType: f.discountType, value: Number(f.discountValue) || 0,
|
||||
lines: f.lines.map((l) => (l.variantId ? { variantId: l.variantId, quantity: l.quantity } : { productId: l.productId, quantity: l.quantity })),
|
||||
qty: f.mixQty, scope: scope("mix"), buyQty: f.buyQty, getQty: f.getQty, getPercent: Number(f.getPercent) || 0,
|
||||
buyScope: scope("buy"), getScope: scope("get"),
|
||||
};
|
||||
const apps = applyOffers(units, [offer]);
|
||||
const totals = allocateByLine(apps);
|
||||
return {
|
||||
rows: rows.map((r) => ({ ...r, price: price(r), discount: totals.get(`x\u0000${r.id}`) || 0 })),
|
||||
total: apps.reduce((s, a) => s + a.discount, 0),
|
||||
};
|
||||
}, [f, prices]);
|
||||
|
||||
const statusKey = data.meta.status as keyof typeof STATUS_LABEL;
|
||||
const previewUrl = data.meta.handle ? `https://${data.shop.domain}/apps/bundles/${data.meta.handle}` : "";
|
||||
|
||||
return (
|
||||
<s-page heading={data.isNew ? "New bundle offer" : f.name || "Bundle offer"} inlineSize="base">
|
||||
<s-link slot="breadcrumb-actions" href="/app/offers">Offers</s-link>
|
||||
<s-button slot="primary-action" variant="primary" onClick={() => save()} {...(saving ? { loading: true } : {})}>Save</s-button>
|
||||
{!data.isNew ? (
|
||||
<>
|
||||
<s-button slot="secondary-actions" onClick={() => save(!f.published)}>{f.published ? "Unpublish" : "Publish"}</s-button>
|
||||
{previewUrl ? <s-button slot="secondary-actions" href={previewUrl} target="_blank">Preview</s-button> : null}
|
||||
<s-button slot="secondary-actions" onClick={() => simple("duplicate")}>Duplicate</s-button>
|
||||
<s-button slot="secondary-actions" onClick={() => simple(data.meta.active ? "archive" : "restore")}>{data.meta.active ? "Archive" : "Restore"}</s-button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{actionData && "publishError" in actionData && actionData.publishError ? (
|
||||
<s-banner tone="critical" heading="Saved, but the store could not be updated"><s-paragraph>{actionData.publishError}</s-paragraph></s-banner>
|
||||
) : null}
|
||||
{data.templateHint ? <s-banner tone="info" heading="Template"><s-paragraph>{data.templateHint}</s-paragraph></s-banner> : null}
|
||||
|
||||
{/* status bar */}
|
||||
<s-section padding="base">
|
||||
<s-stack direction="inline" gap="base" alignItems="center" justifyContent="space-between">
|
||||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||||
<s-badge tone={statusKey in STATUS_TONE ? STATUS_TONE[statusKey] : "neutral"}>{statusKey in STATUS_LABEL ? STATUS_LABEL[statusKey] : "Archived"}</s-badge>
|
||||
<s-switch label="Published" checked={f.published} onChange={(e) => set("published", e.currentTarget.checked)} />
|
||||
</s-stack>
|
||||
{!data.isNew ? <s-button variant="tertiary" href={`/app/analytics?offer=${encodeURIComponent(data.form.name)}`}>{data.meta.orders} orders</s-button> : null}
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Offer">
|
||||
<s-stack gap="base">
|
||||
<s-text-field label="Name" value={f.name} maxLength={80} error={err("name")} details="Shown to customers and as the discount name at checkout." onInput={(e) => set("name", e.currentTarget.value)} />
|
||||
<s-box padding="small-200" background="subdued" borderRadius="base"><s-text>Deal: <s-text type="strong">{summary}</s-text></s-text></s-box>
|
||||
<s-choice-list label="Bundle type" values={[f.type]} error={err("type")} onChange={(e) => set("type", e.currentTarget.values[0] || "fixed")}>
|
||||
<s-choice value="fixed">Fixed bundle — a set list of products and quantities</s-choice>
|
||||
<s-choice value="mix" disabled={!data.plan.types.includes("mix") && f.type !== "mix"}>Mix & match — any N items from chosen products or collections{data.plan.types.includes("mix") ? "" : " (paid plans)"}</s-choice>
|
||||
<s-choice value="bxgy" disabled={!data.plan.types.includes("bxgy") && f.type !== "bxgy"}>Buy X get Y{data.plan.types.includes("bxgy") ? "" : " (paid plans)"}</s-choice>
|
||||
</s-choice-list>
|
||||
{f.type !== "bxgy" ? (
|
||||
<s-grid gridTemplateColumns="1fr 1fr" gap="base">
|
||||
<s-select label="Discount type" value={f.discountType} error={err("discountType")} onChange={(e) => set("discountType", e.currentTarget.value)}>
|
||||
<s-option value="percent">% off</s-option>
|
||||
<s-option value="amount">{data.shop.currency} off</s-option>
|
||||
<s-option value="fixed_price">Bundle price</s-option>
|
||||
</s-select>
|
||||
<s-number-field label={f.discountType === "percent" ? "Percentage" : f.discountType === "amount" ? "Amount off" : "Bundle price"} value={f.discountValue}
|
||||
min={0} step={0.01} suffix={f.discountType === "percent" ? "%" : data.shop.currency} error={err("discountValue")}
|
||||
details="Prices as shown in your store (tax-inclusive stores: including tax)." onInput={(e) => set("discountValue", e.currentTarget.value)} />
|
||||
</s-grid>
|
||||
) : null}
|
||||
{f.type === "mix" ? (
|
||||
<s-number-field label="Number of items" value={String(f.mixQty)} min={1} step={1} error={err("mixQty")} onInput={(e) => set("mixQty", Number(e.currentTarget.value))} />
|
||||
) : null}
|
||||
{f.type === "bxgy" ? (
|
||||
<s-grid gridTemplateColumns="1fr 1fr 1fr" gap="base">
|
||||
<s-number-field label="Buy quantity" value={String(f.buyQty)} min={1} step={1} error={err("buyQty")} onInput={(e) => set("buyQty", Number(e.currentTarget.value))} />
|
||||
<s-number-field label="Get quantity" value={String(f.getQty)} min={1} step={1} error={err("getQty")} onInput={(e) => set("getQty", Number(e.currentTarget.value))} />
|
||||
<s-number-field label="Discount on get items" value={f.getPercent} min={0} max={100} step={1} suffix="%" details="100 = free" error={err("getPercent")} onInput={(e) => set("getPercent", e.currentTarget.value)} />
|
||||
</s-grid>
|
||||
) : null}
|
||||
<s-number-field label="Max uses per order" value={String(f.maxPerOrder)} min={0} step={1} details="0 = no limit. The offer applies as many times as the cart allows." error={err("maxPerOrder")} onInput={(e) => set("maxPerOrder", Number(e.currentTarget.value))} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Products">
|
||||
{f.type === "fixed" ? (
|
||||
<s-stack gap="small-200">
|
||||
<s-text color="subdued">The customer gets the discount when all of these are in the cart, in these quantities. Prices are as shown in your store.</s-text>
|
||||
{err("lines") ? <s-text tone="critical">{err("lines")}</s-text> : null}
|
||||
{f.lines.map((l, i) => (
|
||||
<s-grid key={`${l.productId}${l.variantId}`} gridTemplateColumns="auto 1fr 120px auto" gap="base" alignItems="center">
|
||||
<s-thumbnail src={l.imageUrl || undefined} alt="" size="small-200" />
|
||||
<s-stack gap="small-500">
|
||||
<s-text>{l.title || l.productId}</s-text>
|
||||
<s-text color="subdued">{l.variantId ? "This variant" : "Any variant"}</s-text>
|
||||
</s-stack>
|
||||
<s-number-field label="Quantity" labelAccessibilityVisibility="exclusive" value={String(l.quantity)} min={1} max={99} step={1}
|
||||
onInput={(e) => { const q = Number(e.currentTarget.value); setF((s) => ({ ...s, lines: s.lines.map((x, k) => (k === i ? { ...x, quantity: q } : x)) })); }} />
|
||||
<s-button variant="tertiary" icon="delete" accessibilityLabel={`Remove ${l.title}`} onClick={() => setF((s) => ({ ...s, lines: s.lines.filter((_, k) => k !== i) }))} />
|
||||
</s-grid>
|
||||
))}
|
||||
<s-button onClick={pickLines}>Add products</s-button>
|
||||
</s-stack>
|
||||
) : f.type === "mix" ? (
|
||||
<ScopeList role="mix" title="Eligible items" error={err("scopes_mix")}
|
||||
help="Any items from these products or collections count. The cheapest matching items in the cart are used. Collections don't include sub-collections: add every collection that should count." />
|
||||
) : (
|
||||
<s-stack gap="large">
|
||||
<s-text color="subdued">The most expensive matching items count as the "buy" part; the cheapest remaining "get" items get the discount.</s-text>
|
||||
<ScopeList role="buy" title="Customer buys" error={err("scopes_buy")} help="Products or collections the customer must buy." />
|
||||
<ScopeList role="get" title="Customer gets" error={err("scopes_get")} help="Products or collections that get the discount (can be the same as above)." />
|
||||
</s-stack>
|
||||
)}
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Example cart">
|
||||
<s-stack gap="small-200">
|
||||
<s-text color="subdued">A live check of the discount on sample items (the same engine as checkout). Edit a price to try other carts.</s-text>
|
||||
{preview.rows.length ? (
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Item</s-table-header>
|
||||
<s-table-header format="numeric">Qty</s-table-header>
|
||||
<s-table-header>Unit price</s-table-header>
|
||||
<s-table-header format="numeric">Discount</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{preview.rows.map((r) => (
|
||||
<s-table-row key={r.id}>
|
||||
<s-table-cell>{r.label}</s-table-cell>
|
||||
<s-table-cell>{r.qty}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-number-field label="Unit price" labelAccessibilityVisibility="exclusive" value={String(r.price)} min={0} step={0.01}
|
||||
onInput={(e) => { const v = e.currentTarget.value; setPrices((s) => ({ ...s, [r.productId]: v, [r.id]: v })); }} />
|
||||
</s-table-cell>
|
||||
<s-table-cell>{money(r.discount)}</s-table-cell>
|
||||
</s-table-row>
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
) : <s-text color="subdued">Add products to see an example.</s-text>}
|
||||
<s-text type="strong">Total discount: {money(preview.total)}</s-text>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="When & where">
|
||||
<s-stack gap="base">
|
||||
<s-grid gridTemplateColumns="1fr 1fr" gap="base">
|
||||
<label>
|
||||
<s-text>Starts ({data.shop.timezone})</s-text>
|
||||
<input type="datetime-local" value={f.startsAt} disabled={!data.plan.scheduling} onChange={(e) => set("startsAt", e.target.value)} style={{ width: "100%", fontSize: 16, padding: 6 }} />
|
||||
{err("startsAt") ? <s-text tone="critical">{err("startsAt")}</s-text> : null}
|
||||
</label>
|
||||
<label>
|
||||
<s-text>Ends ({data.shop.timezone})</s-text>
|
||||
<input type="datetime-local" value={f.endsAt} disabled={!data.plan.scheduling} onChange={(e) => set("endsAt", e.target.value)} style={{ width: "100%", fontSize: 16, padding: 6 }} />
|
||||
{err("endsAt") ? <s-text tone="critical">{err("endsAt")}</s-text> : null}
|
||||
</label>
|
||||
</s-grid>
|
||||
{!data.plan.scheduling ? <s-text color="subdued">Scheduling is available on paid plans.</s-text> : null}
|
||||
<s-text-field label="Badge" value={f.badge || ""} maxLength={30} placeholder="Diwali Special" onInput={(e) => set("badge", e.currentTarget.value)} />
|
||||
<s-text-field label="Countries (optional)" value={f.countries.join(", ")} placeholder="CA, US" disabled={!data.plan.markets}
|
||||
details={data.plan.markets ? "ISO country codes. Leave empty for all markets." : "Market targeting is on the Growth plan."} error={err("countries")}
|
||||
onInput={(e) => set("countries", e.currentTarget.value.split(/[\s,]+/).filter(Boolean))} />
|
||||
<s-number-field label="Priority (sequence)" value={String(f.sequence)} step={1} details="Lower runs first. An item counts towards one offer only; earlier offers win." onInput={(e) => set("sequence", Number(e.currentTarget.value))} />
|
||||
<s-checkbox label="Show on the homepage strip" checked={f.showHome} disabled={!data.plan.homepage} onChange={(e) => set("showHome", e.currentTarget.checked)} />
|
||||
<s-checkbox label="Show on product pages" checked={f.showProduct} onChange={(e) => set("showProduct", e.currentTarget.checked)} />
|
||||
<s-checkbox label="Show as a cart hint" checked={f.showCartHints} disabled={!data.plan.cartHints} onChange={(e) => set("showCartHints", e.currentTarget.checked)} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Image & description">
|
||||
<s-stack gap="base">
|
||||
{f.imageUrl ? <s-image src={f.imageUrl} alt="Offer image" aspectRatio="16/9" objectFit="cover" inlineSize="auto" /> : <s-text color="subdued">No image: the card shows up to 4 product pictures.</s-text>}
|
||||
<upload.Form method="post" action="/app/upload" encType="multipart/form-data">
|
||||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||||
<input type="file" name="file" accept="image/*" aria-label="Offer image" onChange={(e) => e.currentTarget.form?.requestSubmit()} />
|
||||
{upload.state !== "idle" ? <s-spinner accessibilityLabel="Uploading" size="base" /> : null}
|
||||
{f.imageUrl ? <s-button variant="tertiary" onClick={() => set("imageUrl", "")}>Remove image</s-button> : null}
|
||||
</s-stack>
|
||||
</upload.Form>
|
||||
<s-text-area label="Description" value={f.description || ""} rows={4} onInput={(e) => set("description", e.currentTarget.value)} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Translations">
|
||||
{data.plan.translations ? (
|
||||
<s-stack gap="base">
|
||||
<s-select label="Language" value={locale} onChange={(e) => setLocale(e.currentTarget.value)}>
|
||||
{["fr", "es", "de", "hi", "pa", "ta", "zh-CN", "ar"].map((l) => <s-option key={l} value={l}>{l}</s-option>)}
|
||||
</s-select>
|
||||
{(["name", "badge", "description"] as const).map((k) => (
|
||||
<s-text-field key={k} label={k === "name" ? "Name" : k === "badge" ? "Badge" : "Description"} value={f.i18n[locale]?.[k] || ""}
|
||||
onInput={(e) => { const v = e.currentTarget.value; setF((s) => ({ ...s, i18n: { ...s.i18n, [locale]: { ...s.i18n[locale], [k]: v } } })); }} />
|
||||
))}
|
||||
</s-stack>
|
||||
) : <s-text color="subdued">Translations are on the Growth plan.</s-text>}
|
||||
</s-section>
|
||||
|
||||
{!data.isNew ? (
|
||||
<s-section heading="History">
|
||||
{data.history.length ? (
|
||||
<s-unordered-list>
|
||||
{data.history.map((h, i) => (
|
||||
<s-list-item key={i}>{h.at} · {h.staffUser || "Staff"} · {h.field}{h.oldValue !== null ? `: ${h.oldValue || "—"} → ${h.newValue || "—"}` : ""}</s-list-item>
|
||||
))}
|
||||
</s-unordered-list>
|
||||
) : <s-text color="subdued">No changes yet.</s-text>}
|
||||
</s-section>
|
||||
) : null}
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
266
app/routes/app.offers._index.tsx
Normal file
266
app/routes/app.offers._index.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
import { useState } from "react";
|
||||
import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { Form, useLoaderData, useNavigation, useSearchParams, useSubmit } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { ensureShop } from "../lib/shop.server";
|
||||
import { bulkAction, moveOffer } from "../lib/offers.server";
|
||||
import { publishShop } from "../lib/publish.server";
|
||||
import { moneyFormatter, offerStatus, offerSummary, STATUS_LABEL, STATUS_TONE, TYPE_LABEL } from "../lib/offer-text";
|
||||
import { formatShopDate } from "../lib/tz";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const url = new URL(request.url);
|
||||
const filter = url.searchParams.get("filter") || "current";
|
||||
const type = url.searchParams.get("type") || "";
|
||||
const q = (url.searchParams.get("q") || "").trim();
|
||||
const now = new Date();
|
||||
|
||||
const where: Prisma.OfferWhereInput = { shopId: shop.id };
|
||||
if (filter === "archived") where.active = false;
|
||||
else {
|
||||
where.active = true;
|
||||
if (filter === "published") where.published = true;
|
||||
if (filter === "current") where.OR = [{ endsAt: null }, { endsAt: { gte: now } }];
|
||||
}
|
||||
if (type) where.type = type;
|
||||
if (q) {
|
||||
where.AND = [{
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ lines: { some: { title: { contains: q, mode: "insensitive" } } } },
|
||||
{ scopes: { some: { title: { contains: q, mode: "insensitive" } } } },
|
||||
],
|
||||
}];
|
||||
}
|
||||
const offers = await db.offer.findMany({
|
||||
where,
|
||||
orderBy: [{ sequence: "asc" }, { startsAt: "desc" }, { createdAt: "desc" }],
|
||||
include: { lines: true, scopes: true, _count: { select: { usages: true } } },
|
||||
});
|
||||
const money = moneyFormatter(shop.currency);
|
||||
return {
|
||||
filter, type, q,
|
||||
offers: offers.map((o) => {
|
||||
const status = offerStatus(o, now);
|
||||
return {
|
||||
id: o.id, name: o.name, badge: o.badge, type: o.type, status,
|
||||
summary: offerSummary(o, money),
|
||||
image: o.imageUrl || o.lines.find((l) => l.imageUrl)?.imageUrl || o.scopes.find((s) => s.imageUrl)?.imageUrl || "",
|
||||
starts: formatShopDate(o.startsAt, shop.timezone),
|
||||
ends: formatShopDate(o.endsAt, shop.timezone),
|
||||
countries: o.countries.join(", "),
|
||||
orders: o._count.usages,
|
||||
published: o.published,
|
||||
active: o.active,
|
||||
limitedByPlan: o.limitedByPlan,
|
||||
};
|
||||
}),
|
||||
publishError: shop.publishError,
|
||||
};
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const form = await request.formData();
|
||||
const intent = String(form.get("intent") || "");
|
||||
const ids = form.getAll("ids").map(String);
|
||||
const staff = session.onlineAccessInfo?.associated_user?.email;
|
||||
if (intent === "move") await moveOffer(shop.id, String(form.get("id")), form.get("direction") === "up" ? "up" : "down");
|
||||
else if (intent === "toggle") await bulkAction(shop.id, [String(form.get("id"))], form.get("published") === "1" ? "publish" : "unpublish", staff);
|
||||
else await bulkAction(shop.id, ids, intent, staff);
|
||||
const outcome = await publishShop(admin, session.shop);
|
||||
return { ok: outcome.ok, error: outcome.error };
|
||||
};
|
||||
|
||||
const FILTERS = [
|
||||
{ value: "current", label: "Current & upcoming" },
|
||||
{ value: "published", label: "Published" },
|
||||
{ value: "all", label: "All (not archived)" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
];
|
||||
|
||||
export default function OffersIndex() {
|
||||
const { offers, filter, type, q, publishError } = useLoaderData<typeof loader>();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const submit = useSubmit();
|
||||
const nav = useNavigation();
|
||||
const busy = nav.state !== "idle";
|
||||
|
||||
const setParam = (k: string, v: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (v) next.set(k, v);
|
||||
else next.delete(k);
|
||||
setParams(next);
|
||||
setSelected([]);
|
||||
};
|
||||
const bulk = (intent: string) => {
|
||||
const fd = new FormData();
|
||||
fd.set("intent", intent);
|
||||
selected.forEach((id) => fd.append("ids", id));
|
||||
submit(fd, { method: "post" });
|
||||
setSelected([]);
|
||||
};
|
||||
const toggleAll = (on: boolean) => setSelected(on ? offers.map((o) => o.id) : []);
|
||||
|
||||
return (
|
||||
<s-page heading="Bundle offers" inlineSize="large">
|
||||
<s-button slot="primary-action" variant="primary" href="/app/offers/new">Create offer</s-button>
|
||||
<s-button slot="secondary-actions" href="/app/templates">Festival templates</s-button>
|
||||
|
||||
{publishError ? (
|
||||
<s-banner tone="critical" heading="Offers could not be published to your store">
|
||||
<s-paragraph>{publishError}</s-paragraph>
|
||||
</s-banner>
|
||||
) : null}
|
||||
|
||||
<s-section padding="base">
|
||||
<s-stack gap="base">
|
||||
<s-grid gridTemplateColumns="2fr 1fr 1fr" gap="base">
|
||||
<Form method="get">
|
||||
<input type="hidden" name="filter" value={filter} />
|
||||
<input type="hidden" name="type" value={type} />
|
||||
<s-search-field label="Search" labelAccessibilityVisibility="exclusive" name="q" defaultValue={q} placeholder="Search by offer or product name" />
|
||||
</Form>
|
||||
<s-select label="Show" labelAccessibilityVisibility="exclusive" value={filter} onChange={(e) => setParam("filter", e.currentTarget.value)}>
|
||||
{FILTERS.map((f) => <s-option key={f.value} value={f.value}>{f.label}</s-option>)}
|
||||
</s-select>
|
||||
<s-select label="Type" labelAccessibilityVisibility="exclusive" value={type} onChange={(e) => setParam("type", e.currentTarget.value)}>
|
||||
<s-option value="">All types</s-option>
|
||||
<s-option value="fixed">Fixed bundle</s-option>
|
||||
<s-option value="mix">Mix & match</s-option>
|
||||
<s-option value="bxgy">Buy X get Y</s-option>
|
||||
</s-select>
|
||||
</s-grid>
|
||||
|
||||
{selected.length ? (
|
||||
<s-stack direction="inline" gap="small-200" alignItems="center">
|
||||
<s-text>{selected.length} selected</s-text>
|
||||
<s-button onClick={() => bulk("publish")} disabled={busy}>Publish</s-button>
|
||||
<s-button onClick={() => bulk("unpublish")} disabled={busy}>Unpublish</s-button>
|
||||
<s-button onClick={() => bulk("duplicate")} disabled={busy}>Duplicate</s-button>
|
||||
{filter === "archived" ? (
|
||||
<>
|
||||
<s-button onClick={() => bulk("unarchive")} disabled={busy}>Restore</s-button>
|
||||
<s-button tone="critical" onClick={() => bulk("delete")} disabled={busy}>Delete</s-button>
|
||||
</>
|
||||
) : (
|
||||
<s-button tone="critical" onClick={() => bulk("archive")} disabled={busy}>Archive</s-button>
|
||||
)}
|
||||
</s-stack>
|
||||
) : null}
|
||||
|
||||
{offers.length === 0 ? (
|
||||
<s-box padding="large-300">
|
||||
<s-stack gap="base" alignItems="center">
|
||||
<s-heading>Create your first bundle offer</s-heading>
|
||||
<s-paragraph>
|
||||
Pick products from the catalogue, choose % off, $ off or a bundle price, and set festival dates.
|
||||
Running offers show on the homepage, on product pages and as hints in the cart.
|
||||
</s-paragraph>
|
||||
<s-stack direction="inline" gap="base">
|
||||
<s-button variant="primary" href="/app/offers/new">Create offer</s-button>
|
||||
<s-button href="/app/templates">Start from a festival template</s-button>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
) : (
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header>
|
||||
<s-checkbox
|
||||
accessibilityLabel="Select all offers"
|
||||
checked={selected.length === offers.length}
|
||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||
/>
|
||||
</s-table-header>
|
||||
<s-table-header>Priority</s-table-header>
|
||||
<s-table-header listSlot="primary">Offer</s-table-header>
|
||||
<s-table-header>Type</s-table-header>
|
||||
<s-table-header>Dates</s-table-header>
|
||||
<s-table-header format="numeric">Orders</s-table-header>
|
||||
<s-table-header listSlot="secondary">Status</s-table-header>
|
||||
<s-table-header>Published</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{offers.map((o, i) => (
|
||||
<s-table-row key={o.id}>
|
||||
<s-table-cell>
|
||||
<s-checkbox
|
||||
accessibilityLabel={`Select ${o.name}`}
|
||||
checked={selected.includes(o.id)}
|
||||
onChange={(e) => {
|
||||
const on = e.currentTarget.checked;
|
||||
setSelected((s) => (on ? [...s, o.id] : s.filter((x) => x !== o.id)));
|
||||
}}
|
||||
/>
|
||||
</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack direction="inline" gap="small-400">
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="move" />
|
||||
<input type="hidden" name="id" value={o.id} />
|
||||
<input type="hidden" name="direction" value="up" />
|
||||
<s-button type="submit" variant="tertiary" icon="arrow-up" accessibilityLabel={`Move ${o.name} up`} disabled={i === 0 || busy} />
|
||||
</Form>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="move" />
|
||||
<input type="hidden" name="id" value={o.id} />
|
||||
<input type="hidden" name="direction" value="down" />
|
||||
<s-button type="submit" variant="tertiary" icon="arrow-down" accessibilityLabel={`Move ${o.name} down`} disabled={i === offers.length - 1 || busy} />
|
||||
</Form>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack direction="inline" gap="base" alignItems="center">
|
||||
<s-thumbnail src={o.image || undefined} alt="" size="small" />
|
||||
<s-stack gap="small-500">
|
||||
<s-link href={`/app/offers/${o.id}`}>{o.name}</s-link>
|
||||
<s-text color="subdued">{o.summary}</s-text>
|
||||
{o.badge ? <s-badge>{o.badge}</s-badge> : null}
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{TYPE_LABEL[o.type]}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack gap="small-500">
|
||||
<s-text>{o.starts ? `From ${o.starts}` : "No start date"}</s-text>
|
||||
<s-text color="subdued">{o.ends ? `Ends ${o.ends}` : "No end date"}</s-text>
|
||||
{o.countries ? <s-text color="subdued">{o.countries}</s-text> : null}
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>{o.orders}</s-table-cell>
|
||||
<s-table-cell>
|
||||
<s-stack gap="small-500">
|
||||
<s-badge tone={STATUS_TONE[o.status]}>{o.active ? STATUS_LABEL[o.status] : "Archived"}</s-badge>
|
||||
{o.limitedByPlan ? <s-badge tone="caution">Over plan limit</s-badge> : null}
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
<s-table-cell>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="toggle" />
|
||||
<input type="hidden" name="id" value={o.id} />
|
||||
<input type="hidden" name="published" value={o.published ? "0" : "1"} />
|
||||
<s-button type="submit" variant="secondary" disabled={!o.active || busy}>
|
||||
{o.published ? "Unpublish" : "Publish"}
|
||||
</s-button>
|
||||
</Form>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
)}
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
67
app/routes/app.plans.tsx
Normal file
67
app/routes/app.plans.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
// Plans use Shopify managed pricing (brief §18): Shopify hosts the plan page, trials and proration.
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { syncShop } from "../lib/shop.server";
|
||||
import { PLANS, planOf } from "../lib/plans";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await syncShop(admin, session.shop); // refresh the plan from activeSubscriptions
|
||||
const store = session.shop.replace(".myshopify.com", "");
|
||||
const handle = process.env.SHOPIFY_APP_HANDLE || "bundle-offers";
|
||||
return {
|
||||
current: shop.planCode,
|
||||
pricingUrl: `https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`,
|
||||
plans: Object.entries(PLANS).map(([code, p]) => ({ code, ...p, runningOffers: p.runningOffers === Infinity ? "Unlimited" : String(p.runningOffers) })),
|
||||
currentName: planOf(shop.planCode).name,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Plans() {
|
||||
const d = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<s-page heading="Plans">
|
||||
<s-button slot="primary-action" variant="primary" href={d.pricingUrl} target="_top">Change plan</s-button>
|
||||
<s-section>
|
||||
<s-paragraph>You're on the <s-text type="strong">{d.currentName}</s-text> plan. Paid plans include a 14-day free trial; development stores are free.</s-paragraph>
|
||||
</s-section>
|
||||
<s-section heading="Compare">
|
||||
<s-table>
|
||||
<s-table-header-row>
|
||||
<s-table-header listSlot="primary">Plan</s-table-header>
|
||||
<s-table-header>Price</s-table-header>
|
||||
<s-table-header>Running offers</s-table-header>
|
||||
<s-table-header>Includes</s-table-header>
|
||||
</s-table-header-row>
|
||||
<s-table-body>
|
||||
{d.plans.map((p) => (
|
||||
<s-table-row key={p.code}>
|
||||
<s-table-cell>{p.name}{p.code === d.current ? " (current)" : ""}</s-table-cell>
|
||||
<s-table-cell>{p.price}</s-table-cell>
|
||||
<s-table-cell>{p.runningOffers}</s-table-cell>
|
||||
<s-table-cell>
|
||||
{[
|
||||
p.types.length === 3 ? "All bundle types" : "Fixed bundles",
|
||||
p.scheduling && "scheduling",
|
||||
p.homepage && "homepage strip",
|
||||
p.cartHints && "cart hints",
|
||||
p.templates && "festival templates",
|
||||
p.analytics && "analytics",
|
||||
p.markets && "market targeting",
|
||||
p.translations && "translations",
|
||||
p.monthlyOrders !== Infinity && `${p.monthlyOrders} bundle orders/month`,
|
||||
p.branding && "“Powered by” link",
|
||||
].filter(Boolean).join(", ")}
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
))}
|
||||
</s-table-body>
|
||||
</s-table>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
119
app/routes/app.settings.tsx
Normal file
119
app/routes/app.settings.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { useActionData, useLoaderData, useNavigation, useSubmit } from "react-router";
|
||||
import { useAppBridge } from "@shopify/app-bridge-react";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { ensureShop } from "../lib/shop.server";
|
||||
import { updateCombinations } from "../lib/publish.server";
|
||||
import { CONFIG_MAX_BYTES, settingsOf, type ShopSettings } from "../lib/compile";
|
||||
|
||||
type Settings = ReturnType<typeof settingsOf> & { menuLinked?: boolean };
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const raw = (shop.settings || {}) as ShopSettings & { menuLinked?: boolean };
|
||||
return {
|
||||
settings: { ...settingsOf(raw), menuLinked: !!raw.menuLinked } as Settings,
|
||||
configBytes: shop.configBytes,
|
||||
storefrontBytes: shop.storefrontBytes,
|
||||
max: CONFIG_MAX_BYTES,
|
||||
};
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const s = JSON.parse(String((await request.formData()).get("payload"))) as Settings;
|
||||
const clean: Settings = {
|
||||
excludeTag: (s.excludeTag || "").trim().slice(0, 40),
|
||||
excludeSubscriptions: !!s.excludeSubscriptions,
|
||||
excludeGiftCards: !!s.excludeGiftCards,
|
||||
bxgyAllocation: s.bxgyAllocation === "get_units_only" ? "get_units_only" : "all_units",
|
||||
combineProduct: !!s.combineProduct,
|
||||
combineOrder: !!s.combineOrder,
|
||||
combineShipping: !!s.combineShipping,
|
||||
afterAdd: s.afterAdd === "drawer" ? "drawer" : "cart",
|
||||
accent: /^#[0-9a-fA-F]{6}$/.test(s.accent || "") ? s.accent : "",
|
||||
radius: Math.min(Math.max(Math.round(Number(s.radius) || 0), 0), 24),
|
||||
menuLinked: !!s.menuLinked,
|
||||
};
|
||||
await db.shop.update({ where: { id: shop.id }, data: { settings: clean } });
|
||||
const outcome = await updateCombinations(admin, session.shop);
|
||||
return { ok: outcome.ok, error: outcome.error };
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, configBytes, storefrontBytes, max } = useLoaderData<typeof loader>();
|
||||
const result = useActionData<typeof action>();
|
||||
const [s, setS] = useState<Settings>(settings);
|
||||
const submit = useSubmit();
|
||||
const nav = useNavigation();
|
||||
const shopify = useAppBridge();
|
||||
useEffect(() => {
|
||||
if (result?.ok) shopify.toast.show("Settings saved");
|
||||
if (result?.error) shopify.toast.show(result.error, { isError: true });
|
||||
}, [result, shopify]);
|
||||
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setS((x) => ({ ...x, [k]: v }));
|
||||
const save = () => {
|
||||
const fd = new FormData();
|
||||
fd.set("payload", JSON.stringify(s));
|
||||
submit(fd, { method: "post" });
|
||||
};
|
||||
const pct = Math.round((configBytes / max) * 100);
|
||||
|
||||
return (
|
||||
<s-page heading="Settings">
|
||||
<s-button slot="primary-action" variant="primary" onClick={save} {...(nav.state !== "idle" ? { loading: true } : {})}>Save</s-button>
|
||||
|
||||
<s-section heading="Exclusions">
|
||||
<s-stack gap="base">
|
||||
<s-text-field label="Exclusion tag" value={s.excludeTag} details="Products with this tag never count towards a bundle (e.g. marketplace sellers' products)." onInput={(e) => set("excludeTag", e.currentTarget.value)} />
|
||||
<s-checkbox label="Exclude subscription items (bought with a selling plan)" checked={s.excludeSubscriptions} onChange={(e) => set("excludeSubscriptions", e.currentTarget.checked)} />
|
||||
<s-checkbox label="Exclude gift cards" checked={s.excludeGiftCards} onChange={(e) => set("excludeGiftCards", e.currentTarget.checked)} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Combining with other discounts">
|
||||
<s-stack gap="base">
|
||||
<s-checkbox label="Combine with other product discounts" checked={s.combineProduct} onChange={(e) => set("combineProduct", e.currentTarget.checked)} />
|
||||
<s-checkbox label="Combine with order discounts" checked={s.combineOrder} onChange={(e) => set("combineOrder", e.currentTarget.checked)} />
|
||||
<s-checkbox label="Combine with shipping discounts" checked={s.combineShipping} onChange={(e) => set("combineShipping", e.currentTarget.checked)} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Buy X get Y">
|
||||
<s-choice-list label="How the discount shows on the items" values={[s.bxgyAllocation]} onChange={(e) => set("bxgyAllocation", (e.currentTarget.values[0] as Settings["bxgyAllocation"]) || "all_units")}>
|
||||
<s-choice value="all_units">Spread over all items of the deal (same total, matches the original module)</s-choice>
|
||||
<s-choice value="get_units_only">Only on the "get" items (the free item shows as free)</s-choice>
|
||||
</s-choice-list>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Storefront">
|
||||
<s-stack gap="base">
|
||||
<s-choice-list label="After “Add bundle to cart”" values={[s.afterAdd]} onChange={(e) => set("afterAdd", (e.currentTarget.values[0] as Settings["afterAdd"]) || "cart")}>
|
||||
<s-choice value="cart">Go to the cart page</s-choice>
|
||||
<s-choice value="drawer">Open the theme's cart drawer (if the theme has one)</s-choice>
|
||||
</s-choice-list>
|
||||
<s-text-field label="Accent colour" value={s.accent} placeholder="#b5462e" details="Leave empty to use your theme's colours." onInput={(e) => set("accent", e.currentTarget.value)} />
|
||||
<s-number-field label="Card corner radius" value={String(s.radius)} min={0} max={24} step={1} suffix="px" onInput={(e) => set("radius", Number(e.currentTarget.value))} />
|
||||
<s-checkbox label="I've added /apps/bundles to my store menu" checked={!!s.menuLinked} onChange={(e) => set("menuLinked", e.currentTarget.checked)} />
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Discount configuration size">
|
||||
<s-stack gap="small-200">
|
||||
<s-text>{configBytes.toLocaleString()} of {max.toLocaleString()} bytes ({pct}%)</s-text>
|
||||
<div role="meter" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Configuration size" style={{ height: 8, borderRadius: 4, background: "#e3e3e3" }}>
|
||||
<div style={{ width: `${Math.min(pct, 100)}%`, height: 8, borderRadius: 4, background: pct > 85 ? "#c70a24" : "#29845a" }} />
|
||||
</div>
|
||||
<s-text color="subdued">All running offers share one Shopify discount so each item counts once. Storefront data: {storefrontBytes.toLocaleString()} bytes.</s-text>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
72
app/routes/app.templates.tsx
Normal file
72
app/routes/app.templates.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { ensureShop } from "../lib/shop.server";
|
||||
import { planOf } from "../lib/plans";
|
||||
import { festivalByName, TEMPLATES, upcomingFestivals } from "../lib/templates";
|
||||
import { moneyFormatter, offerSummary, TYPE_LABEL } from "../lib/offer-text";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { admin, session } = await authenticate.admin(request);
|
||||
const shop = await ensureShop(admin, session.shop);
|
||||
const plan = planOf(shop.planCode);
|
||||
const money = moneyFormatter(shop.currency);
|
||||
const now = new Date();
|
||||
return {
|
||||
allowed: plan.templates,
|
||||
calendar: upcomingFestivals(now).map((f) => ({
|
||||
...f,
|
||||
weeksAway: Math.round((new Date(`${f.date}T00:00:00Z`).getTime() - now.getTime()) / (7 * 86400000)),
|
||||
})),
|
||||
templates: TEMPLATES.map((t) => ({
|
||||
...t,
|
||||
summary: offerSummary({ ...t, mixQty: t.mixQty ?? 3, buyQty: t.buyQty ?? 2, getQty: t.getQty ?? 1, getPercent: t.getPercent ?? 100, active: true, published: true, startsAt: null, endsAt: null }, money),
|
||||
next: festivalByName(t.festival)?.date || "",
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export default function Templates() {
|
||||
const { templates, calendar, allowed } = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<s-page heading="Festival templates" inlineSize="large">
|
||||
{!allowed ? (
|
||||
<s-banner tone="info" heading="Templates and the festival calendar are on the Growth plan">
|
||||
<s-button slot="secondary-actions" href="/app/plans">See plans</s-button>
|
||||
</s-banner>
|
||||
) : null}
|
||||
|
||||
<s-section heading="Festival calendar">
|
||||
<s-stack gap="small-200">
|
||||
{calendar.map((f) => (
|
||||
<s-stack key={f.key} direction="inline" gap="base" justifyContent="space-between">
|
||||
<s-text type="strong">{f.name}</s-text>
|
||||
<s-text>{f.date}{f.approximate ? " (approx.)" : ""}</s-text>
|
||||
<s-badge tone={f.weeksAway <= 6 ? "caution" : "neutral"}>{f.weeksAway <= 0 ? "This week" : `In ${f.weeksAway} weeks`}</s-badge>
|
||||
</s-stack>
|
||||
))}
|
||||
<s-text color="subdued">Plan your offers about 6 weeks ahead. Lunar dates are approximate: check them locally.</s-text>
|
||||
</s-stack>
|
||||
</s-section>
|
||||
|
||||
<s-section heading="Templates">
|
||||
<s-grid gridTemplateColumns="repeat(auto-fill, minmax(260px, 1fr))" gap="base">
|
||||
{templates.map((t) => (
|
||||
<s-box key={t.key} padding="base" borderWidth="base" borderRadius="base">
|
||||
<s-stack gap="small-200">
|
||||
<s-stack direction="inline" gap="small-200"><s-badge>{t.badge}</s-badge><s-badge tone="info">{TYPE_LABEL[t.type]}</s-badge></s-stack>
|
||||
<s-heading>{t.name}</s-heading>
|
||||
<s-text>{t.summary}</s-text>
|
||||
<s-text color="subdued">{t.festival}{t.next ? ` · ${t.next}` : ""}</s-text>
|
||||
<s-button href={`/app/offers/new?template=${t.key}`} disabled={!allowed}>Use template</s-button>
|
||||
</s-stack>
|
||||
</s-box>
|
||||
))}
|
||||
</s-grid>
|
||||
</s-section>
|
||||
</s-page>
|
||||
);
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => boundary.headers(headersArgs);
|
||||
40
app/routes/app.tsx
Normal file
40
app/routes/app.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { Outlet, useLoaderData, useRouteError } from "react-router";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
import { AppProvider } from "@shopify/shopify-app-react-router/react";
|
||||
|
||||
import { authenticate } from "../shopify.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
await authenticate.admin(request);
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { apiKey } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppProvider apiKey={apiKey}>
|
||||
<s-app-nav>
|
||||
<s-link href="/app">Home</s-link>
|
||||
<s-link href="/app/offers">Offers</s-link>
|
||||
<s-link href="/app/templates">Festival templates</s-link>
|
||||
<s-link href="/app/analytics">Analytics</s-link>
|
||||
<s-link href="/app/settings">Settings</s-link>
|
||||
<s-link href="/app/plans">Plans</s-link>
|
||||
</s-app-nav>
|
||||
<Outlet />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Shopify needs React Router to catch some thrown responses, so that their headers are included in the response.
|
||||
export function ErrorBoundary() {
|
||||
return boundary.error(useRouteError());
|
||||
}
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => {
|
||||
return boundary.headers(headersArgs);
|
||||
};
|
||||
62
app/routes/app.upload.tsx
Normal file
62
app/routes/app.upload.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
// Offer image upload: staged upload -> Shopify Files (write_files). Returns the CDN url once processed.
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { gql } from "../lib/shop.server";
|
||||
|
||||
const STAGE = `#graphql
|
||||
mutation BundleOffersStage($input: [StagedUploadInput!]!) {
|
||||
stagedUploadsCreate(input: $input) {
|
||||
stagedTargets { url resourceUrl parameters { name value } }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
const CREATE = `#graphql
|
||||
mutation BundleOffersFile($files: [FileCreateInput!]!) {
|
||||
fileCreate(files: $files) {
|
||||
files { id fileStatus ... on MediaImage { image { url } } }
|
||||
userErrors { field message }
|
||||
}
|
||||
}`;
|
||||
|
||||
const STATUS = `#graphql
|
||||
query BundleOffersFileStatus($id: ID!) {
|
||||
node(id: $id) { ... on MediaImage { fileStatus image { url } } }
|
||||
}`;
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { admin } = await authenticate.admin(request);
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || !file.size) return { error: "Choose an image." };
|
||||
if (!file.type.startsWith("image/")) return { error: "Use a JPG, PNG, GIF or WebP image." };
|
||||
if (file.size > MAX_BYTES) return { error: "Images must be 10 MB or smaller." };
|
||||
|
||||
const staged = await gql(admin, STAGE, {
|
||||
input: [{ filename: file.name, mimeType: file.type, resource: "IMAGE", httpMethod: "POST", fileSize: String(file.size) }],
|
||||
});
|
||||
const target = staged.stagedUploadsCreate.stagedTargets?.[0];
|
||||
if (!target) return { error: staged.stagedUploadsCreate.userErrors?.[0]?.message || "Upload failed." };
|
||||
|
||||
const body = new FormData();
|
||||
for (const p of target.parameters) body.append(p.name, p.value);
|
||||
body.append("file", file);
|
||||
const up = await fetch(target.url, { method: "POST", body });
|
||||
if (!up.ok) return { error: `Upload failed (${up.status}).` };
|
||||
|
||||
const created = await gql(admin, CREATE, { files: [{ originalSource: target.resourceUrl, contentType: "IMAGE", alt: "Bundle offer image" }] });
|
||||
const f = created.fileCreate.files?.[0];
|
||||
if (!f) return { error: created.fileCreate.userErrors?.[0]?.message || "Could not save the image." };
|
||||
if (f.image?.url) return { url: f.image.url };
|
||||
|
||||
// processing is asynchronous: poll for a few seconds
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const s = await gql(admin, STATUS, { id: f.id });
|
||||
if (s.node?.image?.url) return { url: s.node.image.url as string };
|
||||
if (s.node?.fileStatus === "FAILED") return { error: "Shopify could not process this image." };
|
||||
}
|
||||
return { error: "The image is still processing. Try saving again in a minute." };
|
||||
};
|
||||
14
app/routes/auth.$.tsx
Normal file
14
app/routes/auth.$.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { boundary } from "@shopify/shopify-app-react-router/server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
await authenticate.admin(request);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const headers: HeadersFunction = (headersArgs) => {
|
||||
return boundary.headers(headersArgs);
|
||||
};
|
||||
16
app/routes/auth.login/error.server.tsx
Normal file
16
app/routes/auth.login/error.server.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { LoginError } from "@shopify/shopify-app-react-router/server";
|
||||
import { LoginErrorType } from "@shopify/shopify-app-react-router/server";
|
||||
|
||||
interface LoginErrorMessage {
|
||||
shop?: string;
|
||||
}
|
||||
|
||||
export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage {
|
||||
if (loginErrors?.shop === LoginErrorType.MissingShop) {
|
||||
return { shop: "Please enter your shop domain to log in" };
|
||||
} else if (loginErrors?.shop === LoginErrorType.InvalidShop) {
|
||||
return { shop: "Please enter a valid shop domain to log in" };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
49
app/routes/auth.login/route.tsx
Normal file
49
app/routes/auth.login/route.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { Form, useActionData, useLoaderData } from "react-router";
|
||||
|
||||
import { login } from "../../shopify.server";
|
||||
import { loginErrorMessage } from "./error.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const errors = loginErrorMessage(await login(request));
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const errors = loginErrorMessage(await login(request));
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
export default function Auth() {
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const [shop, setShop] = useState("");
|
||||
const { errors } = actionData || loaderData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<script src="https://cdn.shopify.com/shopifycloud/polaris.js" />
|
||||
<s-page>
|
||||
<Form method="post">
|
||||
<s-section heading="Log in">
|
||||
<s-text-field
|
||||
name="shop"
|
||||
label="Shop domain"
|
||||
details="example.myshopify.com"
|
||||
value={shop}
|
||||
onChange={(e) => setShop(e.currentTarget.value)}
|
||||
autocomplete="on"
|
||||
error={errors.shop}
|
||||
></s-text-field>
|
||||
<s-button type="submit">Log in</s-button>
|
||||
</s-section>
|
||||
</Form>
|
||||
</s-page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
app/routes/proxy.$handle.tsx
Normal file
28
app/routes/proxy.$handle.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
// /apps/bundles/{handle} (app proxy): bundle page (F13, F14). Guests welcome; not running -> 404;
|
||||
// renamed offer -> 301 to the current handle.
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { PROXY_CACHE, runningOffersForShop } from "../lib/proxy.server";
|
||||
import { bundlePageLiquid, notFoundLiquid, proxyBase } from "../lib/storefront.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { liquid } = await authenticate.public.appProxy(request);
|
||||
const url = new URL(request.url);
|
||||
const shop = url.searchParams.get("shop") || "";
|
||||
const base = proxyBase(url.searchParams.get("path_prefix"));
|
||||
const handle = (params.handle || "").toLowerCase();
|
||||
const locale = request.headers.get("accept-language")?.split(",")[0]?.split("-")[0] || null;
|
||||
const data = await runningOffersForShop(shop, request.headers.get("x-shopify-country") || null);
|
||||
if (!data) return liquid(notFoundLiquid(base), { status: 404 });
|
||||
|
||||
const offer = data.running.find((o) => o.handle === handle);
|
||||
if (offer) return liquid(bundlePageLiquid(offer, data.ctx, locale), { headers: { "Cache-Control": PROXY_CACHE } });
|
||||
|
||||
const renamed = await db.offer.findFirst({ where: { shopId: data.shop.id, oldHandles: { has: handle } }, select: { id: true, handle: true } });
|
||||
if (renamed && data.running.some((o) => o.id === renamed.id)) {
|
||||
return redirect(`${base}/${renamed.handle}`, 301);
|
||||
}
|
||||
return liquid(notFoundLiquid(base), { status: 404 });
|
||||
};
|
||||
16
app/routes/proxy._index.tsx
Normal file
16
app/routes/proxy._index.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
// /apps/bundles (app proxy): grid of all running offers (F12).
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { PROXY_CACHE, runningOffersForShop } from "../lib/proxy.server";
|
||||
import { bundlesListLiquid, proxyBase } from "../lib/storefront.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { liquid } = await authenticate.public.appProxy(request);
|
||||
const url = new URL(request.url);
|
||||
const shop = url.searchParams.get("shop") || "";
|
||||
const base = proxyBase(url.searchParams.get("path_prefix"));
|
||||
const locale = request.headers.get("accept-language")?.split(",")[0]?.split("-")[0] || null;
|
||||
const data = await runningOffersForShop(shop, request.headers.get("x-shopify-country") || null);
|
||||
if (!data) return liquid(bundlesListLiquid([], { currency: "USD", timezone: "UTC", branding: false }, null, base));
|
||||
return liquid(bundlesListLiquid(data.running, data.ctx, locale, base), { headers: { "Cache-Control": PROXY_CACHE } });
|
||||
};
|
||||
21
app/routes/webhooks.app.scopes_update.tsx
Normal file
21
app/routes/webhooks.app.scopes_update.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { payload, session, topic, shop } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
|
||||
const current = payload.current as string[];
|
||||
if (session) {
|
||||
await db.session.update({
|
||||
where: {
|
||||
id: session.id
|
||||
},
|
||||
data: {
|
||||
scope: current.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response();
|
||||
};
|
||||
30
app/routes/webhooks.app.subscriptions_update.tsx
Normal file
30
app/routes/webhooks.app.subscriptions_update.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate, unauthenticated } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { planCodeFromSubscription, planOf } from "../lib/plans";
|
||||
import { enforcePlanOnPublished } from "../lib/offers.server";
|
||||
import { publishShop } from "../lib/publish.server";
|
||||
|
||||
// Managed pricing plan changes (§18.2): store the plan, unpublish offers above the new limit, republish.
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, payload } = await authenticate.webhook(request);
|
||||
const sub = (payload as { app_subscription?: { name?: string; status?: string } }).app_subscription;
|
||||
const active = sub?.status?.toUpperCase() === "ACTIVE";
|
||||
const planCode = active ? planCodeFromSubscription(sub?.name) : "free";
|
||||
const row = await db.shop.findUnique({ where: { domain: shop } });
|
||||
if (!row) return new Response();
|
||||
await db.shop.update({ where: { id: row.id }, data: { planCode } });
|
||||
const plan = planOf(planCode);
|
||||
const unpublished = await enforcePlanOnPublished(row.id, plan.runningOffers, plan.types);
|
||||
if (unpublished) {
|
||||
await db.auditLog.create({ data: { shopId: row.id, field: "plan", newValue: `${plan.name}: ${unpublished} offer(s) unpublished (over the plan limit)` } });
|
||||
}
|
||||
try {
|
||||
const { admin } = await unauthenticated.admin(shop);
|
||||
await publishShop(admin, shop);
|
||||
} catch (e) {
|
||||
console.error("publish after plan change failed", e);
|
||||
await db.shop.update({ where: { id: row.id }, data: { nextSyncAt: new Date() } });
|
||||
}
|
||||
return new Response();
|
||||
};
|
||||
16
app/routes/webhooks.app.uninstalled.tsx
Normal file
16
app/routes/webhooks.app.uninstalled.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
// The access token is already revoked here, so the app can't call the Admin API. Shopify removes the app's
|
||||
// functions with the app, so the automatic discount stops applying, and it deletes app-owned ($app and
|
||||
// app-data) metafields. We mark the shop and keep its data for 48 h, until shop/redact deletes it.
|
||||
// The in-app "Remove all Bundle Offers data" action (Settings) deletes the discount explicitly.
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, session, topic } = await authenticate.webhook(request);
|
||||
console.log(`Received ${topic} webhook for ${shop}`);
|
||||
|
||||
await db.shop.updateMany({ where: { domain: shop }, data: { uninstalledAt: new Date(), discountNodeId: null, nextSyncAt: null } });
|
||||
if (session) await db.session.deleteMany({ where: { shop } });
|
||||
return new Response();
|
||||
};
|
||||
52
app/routes/webhooks.catalog.tsx
Normal file
52
app/routes/webhooks.catalog.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
// products/update: refresh cached titles, handles and images used by the admin and the storefront.
|
||||
// products/delete, collections/delete: remove them from offers and log it in the offer history.
|
||||
// A published fixed bundle that lost a product is unpublished so the merchant reviews it.
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic, payload } = await authenticate.webhook(request);
|
||||
const row = await db.shop.findUnique({ where: { domain: shop } });
|
||||
if (!row) return new Response();
|
||||
const p = payload as { id?: number | string; title?: string; handle?: string; image?: { src?: string } | null; images?: { src?: string }[] };
|
||||
const id = String(p.id ?? "");
|
||||
if (!id) return new Response();
|
||||
const offerFilter = { offer: { shopId: row.id } };
|
||||
|
||||
if (topic === "PRODUCTS_UPDATE") {
|
||||
const image = p.image?.src || p.images?.[0]?.src || null;
|
||||
await db.offerLine.updateMany({ where: { ...offerFilter, productId: id, variantId: null }, data: { title: p.title } });
|
||||
await db.offerLine.updateMany({ where: { ...offerFilter, productId: id }, data: { handle: p.handle, imageUrl: image } });
|
||||
await db.offerScope.updateMany({ where: { ...offerFilter, kind: "product", refId: id }, data: { title: p.title, handle: p.handle, imageUrl: image } });
|
||||
await db.offerScope.updateMany({ where: { ...offerFilter, kind: "variant", parentId: id }, data: { handle: p.handle, imageUrl: image } });
|
||||
await db.shop.update({ where: { id: row.id }, data: { nextSyncAt: new Date() } });
|
||||
return new Response();
|
||||
}
|
||||
|
||||
const isProduct = topic === "PRODUCTS_DELETE";
|
||||
const lines = isProduct ? await db.offerLine.findMany({ where: { ...offerFilter, productId: id } }) : [];
|
||||
const scopes = await db.offerScope.findMany({
|
||||
where: isProduct
|
||||
? { ...offerFilter, OR: [{ kind: "product", refId: id }, { kind: "variant", parentId: id }] }
|
||||
: { ...offerFilter, kind: "collection", refId: id },
|
||||
});
|
||||
if (!lines.length && !scopes.length) return new Response();
|
||||
await db.offerLine.deleteMany({ where: { id: { in: lines.map((l) => l.id) } } });
|
||||
await db.offerScope.deleteMany({ where: { id: { in: scopes.map((s) => s.id) } } });
|
||||
const affected = [...new Set([...lines.map((l) => l.offerId), ...scopes.map((s) => s.offerId)])];
|
||||
await db.auditLog.createMany({
|
||||
data: affected.map((offerId) => ({
|
||||
shopId: row.id,
|
||||
offerId,
|
||||
field: isProduct ? "product deleted" : "collection deleted",
|
||||
oldValue: lines.find((l) => l.offerId === offerId)?.title || scopes.find((s) => s.offerId === offerId)?.title || id,
|
||||
newValue: "removed from the offer",
|
||||
})),
|
||||
});
|
||||
if (isProduct) {
|
||||
await db.offer.updateMany({ where: { id: { in: affected }, type: "fixed", published: true }, data: { published: false } });
|
||||
}
|
||||
await db.shop.update({ where: { id: row.id }, data: { nextSyncAt: new Date() } });
|
||||
return new Response();
|
||||
};
|
||||
27
app/routes/webhooks.compliance.tsx
Normal file
27
app/routes/webhooks.compliance.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
|
||||
// Mandatory GDPR webhooks. Bundle Offers stores no customer personal data: only order ids, order names and
|
||||
// amounts for analytics (no names, emails or addresses). customers/data_request and customers/redact have
|
||||
// nothing to return or erase; shop/redact (48 h after uninstall) deletes everything about the shop.
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, topic } = await authenticate.webhook(request);
|
||||
switch (topic) {
|
||||
case "CUSTOMERS_DATA_REQUEST":
|
||||
case "CUSTOMERS_REDACT":
|
||||
break;
|
||||
case "SHOP_REDACT": {
|
||||
const row = await db.shop.findUnique({ where: { domain: shop }, select: { id: true } });
|
||||
if (row) {
|
||||
await db.orderStat.deleteMany({ where: { shopId: row.id } });
|
||||
await db.shop.delete({ where: { id: row.id } }); // cascades to offers, usage and audit log
|
||||
}
|
||||
await db.session.deleteMany({ where: { shop } });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return new Response("Unhandled topic", { status: 404 });
|
||||
}
|
||||
return new Response();
|
||||
};
|
||||
21
app/routes/webhooks.orders.create.tsx
Normal file
21
app/routes/webhooks.orders.create.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import db from "../db.server";
|
||||
import { DISCOUNT_TITLE, monthKey } from "../lib/publish.server";
|
||||
import { recordOrder, type OrderPayload } from "../lib/orders.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const { shop, payload } = await authenticate.webhook(request);
|
||||
const row = await db.shop.findUnique({ where: { domain: shop } });
|
||||
if (!row || row.uninstalledAt) return new Response();
|
||||
const offers = await db.offer.findMany({ where: { shopId: row.id }, select: { id: true, name: true } });
|
||||
const result = await recordOrder(row.id, payload as OrderPayload, offers, DISCOUNT_TITLE);
|
||||
if (result.hasBundle && result.isNew) {
|
||||
const key = monthKey();
|
||||
await db.shop.update({
|
||||
where: { id: row.id },
|
||||
data: row.monthKey === key ? { monthOrders: { increment: 1 } } : { monthKey: key, monthOrders: 1 },
|
||||
});
|
||||
}
|
||||
return new Response();
|
||||
};
|
||||
34
app/shopify.server.ts
Normal file
34
app/shopify.server.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import "@shopify/shopify-app-react-router/adapters/node";
|
||||
import {
|
||||
ApiVersion,
|
||||
AppDistribution,
|
||||
shopifyApp,
|
||||
} from "@shopify/shopify-app-react-router/server";
|
||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||
import prisma from "./db.server";
|
||||
|
||||
const shopify = shopifyApp({
|
||||
apiKey: process.env.SHOPIFY_API_KEY,
|
||||
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
||||
apiVersion: ApiVersion.October26,
|
||||
scopes: process.env.SCOPES?.split(","),
|
||||
appUrl: process.env.SHOPIFY_APP_URL || "",
|
||||
authPathPrefix: "/auth",
|
||||
sessionStorage: new PrismaSessionStorage(prisma),
|
||||
distribution: AppDistribution.AppStore,
|
||||
future: {
|
||||
expiringOfflineAccessTokens: true,
|
||||
},
|
||||
...(process.env.SHOP_CUSTOM_DOMAIN
|
||||
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export default shopify;
|
||||
export const apiVersion = ApiVersion.October26;
|
||||
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
|
||||
export const authenticate = shopify.authenticate;
|
||||
export const unauthenticated = shopify.unauthenticated;
|
||||
export const login = shopify.login;
|
||||
export const registerWebhooks = shopify.registerWebhooks;
|
||||
export const sessionStorage = shopify.sessionStorage;
|
||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@ -0,0 +1,53 @@
|
||||
# Production stack for one VPS: Postgres + the app + Caddy (automatic HTTPS from Let's Encrypt).
|
||||
# Usage: cp .env.example .env (fill it in) then docker compose up -d --build
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: bundle_offers
|
||||
POSTGRES_USER: bundle_offers
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U bundle_offers -d bundle_offers"]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://bundle_offers:${POSTGRES_PASSWORD}@db:5432/bundle_offers?schema=public
|
||||
SHOPIFY_API_KEY: ${SHOPIFY_API_KEY:?set SHOPIFY_API_KEY in .env}
|
||||
SHOPIFY_API_SECRET: ${SHOPIFY_API_SECRET:?set SHOPIFY_API_SECRET in .env}
|
||||
SHOPIFY_APP_URL: https://${APP_DOMAIN:?set APP_DOMAIN in .env}
|
||||
SHOPIFY_APP_HANDLE: ${SHOPIFY_APP_HANDLE:-bundle-offers}
|
||||
SCOPES: read_products,write_discounts,read_discounts,read_orders,write_files,read_themes,write_app_proxy
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on: [app]
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
environment:
|
||||
APP_DOMAIN: ${APP_DOMAIN}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
49
engine/config.js
Normal file
49
engine/config.js
Normal file
@ -0,0 +1,49 @@
|
||||
// Turns the compact $app:configuration JSON (spec/offer-config.schema.json) into engine offers.
|
||||
// Ids in the config are the numeric part of Shopify GIDs; the function compares numeric ids.
|
||||
|
||||
export function numericId(gid) {
|
||||
if (gid == null) return '';
|
||||
const s = String(gid);
|
||||
const i = s.lastIndexOf('/');
|
||||
return i >= 0 ? s.slice(i + 1) : s;
|
||||
}
|
||||
|
||||
function toScope(sc) {
|
||||
return {
|
||||
variantIds: new Set(sc?.v || []),
|
||||
productIds: new Set(sc?.p || []),
|
||||
collectionIds: new Set(sc?.c || []),
|
||||
};
|
||||
}
|
||||
|
||||
/** Money values in the config are in shop currency; cart prices are in presentment currency. */
|
||||
function convertValue(o, rate) {
|
||||
if (o.dt === 'amount' || o.dt === 'fixed_price') return o.dv * rate;
|
||||
return o.dv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} cfg parsed configuration
|
||||
* @param {{rate:number, country?:string, pastGuard?:boolean, guardAt?:string}} ctx
|
||||
*/
|
||||
export function offersFromConfig(cfg, ctx) {
|
||||
const rate = ctx.rate > 0 ? ctx.rate : 1;
|
||||
const out = [];
|
||||
for (const o of cfg.offers || []) {
|
||||
// Safety net for a late scheduler: once the guard time has passed, drop every offer that ends at or
|
||||
// before it. The app republishes the configuration at each boundary; this only covers the gap.
|
||||
if (ctx.pastGuard && o.e && cfg.g && o.e <= cfg.g) continue;
|
||||
if (o.cc && o.cc.length && (!ctx.country || !o.cc.includes(ctx.country))) continue;
|
||||
const base = { id: o.id, type: o.t, maxUses: o.mu || 0 };
|
||||
if (o.t === 'fixed') {
|
||||
out.push({ ...base, discountType: o.dt, value: convertValue(o, rate),
|
||||
lines: (o.l || []).map((l) => ({ variantId: l.v, productId: l.v ? undefined : l.p, quantity: l.q })) });
|
||||
} else if (o.t === 'mix') {
|
||||
out.push({ ...base, discountType: o.dt, value: convertValue(o, rate), qty: o.q, scope: toScope(o.sc) });
|
||||
} else if (o.t === 'bxgy') {
|
||||
out.push({ ...base, buyQty: o.bq, getQty: o.gq, getPercent: o.gp,
|
||||
buyScope: toScope(o.bs), getScope: toScope(o.gs) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
24
engine/engine.d.ts
vendored
Normal file
24
engine/engine.d.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
// Types for engine.js (used by the admin editor's example-cart preview).
|
||||
export interface EngineScope { variantIds?: Set<string>; productIds?: Set<string>; collectionIds?: Set<string> }
|
||||
export interface EngineOffer {
|
||||
id: string;
|
||||
type: "fixed" | "mix" | "bxgy" | string;
|
||||
maxUses?: number;
|
||||
discountType?: string;
|
||||
value?: number;
|
||||
lines?: { variantId?: string; productId?: string; quantity: number }[];
|
||||
qty?: number;
|
||||
scope?: EngineScope;
|
||||
buyQty?: number;
|
||||
getQty?: number;
|
||||
getPercent?: number;
|
||||
buyScope?: EngineScope;
|
||||
getScope?: EngineScope;
|
||||
}
|
||||
export interface EngineUnit { lineId: string; variantId: string; productId: string; collectionIds: string[]; price: number }
|
||||
export interface EngineApplication { offerId: string; discount: number; units: EngineUnit[]; getUnits: EngineUnit[] | null }
|
||||
export function bundleDiscount(regular: number, discountType: string, value: number): number;
|
||||
export function applyOffers(units: EngineUnit[], offers: EngineOffer[]): EngineApplication[];
|
||||
export function allocateByLine(apps: EngineApplication[], options?: { bxgyGetUnitsOnly?: boolean }): Map<string, number>;
|
||||
export function largestRemainderCents(amounts: number[]): number[];
|
||||
export function toUnits(lines: { id: string; variantId: string; productId: string; collectionIds?: string[]; unitPrice: number; quantity: number }[]): EngineUnit[];
|
||||
178
engine/engine.js
Normal file
178
engine/engine.js
Normal file
@ -0,0 +1,178 @@
|
||||
// JavaScript port of the Odoo bundle engine
|
||||
// (reference/odoo-c2c_bundle_offers/models/bundle_engine.py).
|
||||
//
|
||||
// Pure functions, no Shopify imports. Used by the admin app (live example-cart preview) and as the reference
|
||||
// for the deployed Rust function (extensions/bundle-discount/src/engine.rs). Both must keep passing
|
||||
// spec/test-vectors.json (npm test).
|
||||
//
|
||||
// Behaviour that MUST be preserved (it decides which items are discounted):
|
||||
// * One unit per item; pool order = cart line order, then unit order.
|
||||
// * Offers are tried in priority order; every unit an offer uses leaves the pool.
|
||||
// * An offer repeats while it still matches, up to maxUses (0 = unlimited).
|
||||
// * _take sorts matching units by price with a STABLE sort (ties keep pool order).
|
||||
// * fixed: per bundle line, cheapest matching units first; all-or-nothing (pool restored on failure).
|
||||
// * mix: cheapest matching units first.
|
||||
// * bxgy: "buy" = MOST expensive matching units, "get" = cheapest remaining matching units.
|
||||
// * A use that gives 0 discount puts its units back at the END of the pool and stops that offer.
|
||||
// * The discount of a use is spread over ALL its units in proportion to price (bxgy included).
|
||||
|
||||
export function bundleDiscount(regular, discountType, value) {
|
||||
regular = Math.max(regular, 0);
|
||||
value = Math.max(value || 0, 0);
|
||||
if (discountType === 'percent') return (regular * Math.min(value, 100)) / 100;
|
||||
if (discountType === 'amount') return Math.min(value, regular);
|
||||
if (discountType === 'fixed_price') return Math.max(regular - value, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function matches(unit, scope) {
|
||||
// scope = { variantIds: Set, productIds: Set, collectionIds: Set }
|
||||
if (scope.variantIds && scope.variantIds.has(unit.variantId)) return true;
|
||||
if (scope.productIds && scope.productIds.has(unit.productId)) return true;
|
||||
if (scope.collectionIds) for (const c of unit.collectionIds) if (scope.collectionIds.has(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Performance (Shopify instruction limit): the reference port re-filtered and re-sorted the whole pool on
|
||||
// every use. Here each offer builds its sorted candidate lists ONCE and consumes them in order. Because units
|
||||
// only ever leave the pool while an offer repeats (they are appended back only when the offer stops), the
|
||||
// remaining candidates keep exactly the order a fresh filter + stable sort would give, so results are identical
|
||||
// (proved by the vectors and tests/differential.test.js against tests/reference/bundle-engine.reference.js).
|
||||
|
||||
function candidates(pool, predicate, cheapestFirst) {
|
||||
const found = pool.filter(predicate); // keeps pool order
|
||||
found.sort((a, b) => (cheapestFirst ? a.price - b.price : b.price - a.price)); // stable (ES2019+)
|
||||
return { list: found, pos: 0 };
|
||||
}
|
||||
|
||||
/** Take qty not-yet-used units from a candidate list (all or nothing). */
|
||||
function takeFrom(cand, used, qty) {
|
||||
const taken = [];
|
||||
let i = cand.pos;
|
||||
while (i < cand.list.length && taken.length < qty) {
|
||||
const unit = cand.list[i];
|
||||
if (!used.has(unit)) taken.push(unit);
|
||||
i += 1;
|
||||
}
|
||||
if (taken.length < qty) return null;
|
||||
for (const unit of taken) used.add(unit);
|
||||
while (cand.pos < cand.list.length && used.has(cand.list[cand.pos])) cand.pos += 1;
|
||||
return taken;
|
||||
}
|
||||
|
||||
function sum(units) { let s = 0; for (const u of units) s += u.price; return s; }
|
||||
|
||||
/** Runs one offer as many times as allowed against the pool; returns its applications and the new pool. */
|
||||
function runOffer(pool, offer) {
|
||||
const apps = [];
|
||||
const used = new Set(); // units taken by this offer so far (left the pool)
|
||||
let returned = null; // units of a zero-discount use: go back at the END of the pool
|
||||
const maxUses = offer.maxUses || 0;
|
||||
let lists;
|
||||
if (offer.type === 'fixed') {
|
||||
const lines = (offer.lines || []).filter((l) => l.quantity > 0);
|
||||
if (!lines.length) return { apps, pool };
|
||||
lists = lines.map((line) => ({
|
||||
qty: line.quantity,
|
||||
cand: candidates(pool, line.variantId
|
||||
? (u) => u.variantId === line.variantId
|
||||
: (u) => u.productId === line.productId, true), // product-level line: any variant
|
||||
}));
|
||||
} else if (offer.type === 'mix') {
|
||||
if (!(offer.qty > 0)) return { apps, pool };
|
||||
lists = [{ qty: offer.qty, cand: candidates(pool, (u) => matches(u, offer.scope), true) }];
|
||||
} else if (offer.type === 'bxgy') {
|
||||
if (!(offer.buyQty > 0) || !(offer.getQty > 0)) return { apps, pool };
|
||||
lists = [
|
||||
{ qty: offer.buyQty, cand: candidates(pool, (u) => matches(u, offer.buyScope), false) },
|
||||
{ qty: offer.getQty, cand: candidates(pool, (u) => matches(u, offer.getScope), true) },
|
||||
];
|
||||
} else {
|
||||
return { apps, pool };
|
||||
}
|
||||
|
||||
while (!maxUses || apps.length < maxUses) {
|
||||
const parts = [];
|
||||
let failed = false;
|
||||
for (const part of lists) {
|
||||
const savedPos = part.cand.pos;
|
||||
const taken = takeFrom(part.cand, used, part.qty);
|
||||
if (!taken) { failed = true; part.cand.pos = savedPos; break; }
|
||||
parts.push(taken);
|
||||
}
|
||||
if (failed) { // all or nothing: restore and stop
|
||||
for (const taken of parts) for (const unit of taken) used.delete(unit);
|
||||
break;
|
||||
}
|
||||
const units = [].concat(...parts);
|
||||
let discount;
|
||||
let getUnits = null;
|
||||
if (offer.type === 'bxgy') {
|
||||
getUnits = parts[1];
|
||||
const pct = Math.min(Math.max(offer.getPercent || 0, 0), 100);
|
||||
discount = (sum(getUnits) * pct) / 100;
|
||||
} else {
|
||||
discount = bundleDiscount(sum(units), offer.discountType, offer.value);
|
||||
}
|
||||
if (discount <= 0) { returned = units; for (const unit of units) used.delete(unit); break; }
|
||||
apps.push({ offerId: offer.id, discount, units, getUnits });
|
||||
}
|
||||
|
||||
if (!used.size && !returned) return { apps, pool };
|
||||
const back = new Set(returned || []);
|
||||
const next = pool.filter((u) => !used.has(u) && !back.has(u));
|
||||
if (returned) next.push(...returned);
|
||||
return { apps, pool: next };
|
||||
}
|
||||
|
||||
/** units: [{lineId, variantId, productId, collectionIds: string[], price}] ; offers in priority order */
|
||||
export function applyOffers(units, offers) {
|
||||
let pool = units.slice();
|
||||
const applications = [];
|
||||
for (const offer of offers) {
|
||||
const r = runOffer(pool, offer);
|
||||
pool = r.pool;
|
||||
for (const app of r.apps) applications.push(app);
|
||||
}
|
||||
return applications;
|
||||
}
|
||||
|
||||
/** Discount per (offerId, lineId), spread by price exactly like the Odoo split_by().
|
||||
* options.bxgyGetUnitsOnly: Shopify-only shop setting; spreads a bxgy discount over the "get" units only
|
||||
* (default false = Odoo parity, covered by the vectors). */
|
||||
export function allocateByLine(applications, options = {}) {
|
||||
const totals = new Map();
|
||||
for (const app of applications) {
|
||||
const units = options.bxgyGetUnitsOnly && app.getUnits ? app.getUnits : app.units;
|
||||
const base = sum(units);
|
||||
for (const unit of units) {
|
||||
const share = base ? app.discount * (unit.price / base) : app.discount / units.length;
|
||||
const key = app.offerId + '\u0000' + unit.lineId;
|
||||
totals.set(key, (totals.get(key) || 0) + share);
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Round amounts to cents so they add up to round(total): largest remainder, ties by input order. */
|
||||
export function largestRemainderCents(amounts) {
|
||||
const exact = amounts.map((a) => a * 100);
|
||||
const target = Math.round(amounts.reduce((s, a) => s + a, 0) * 100 + 1e-9);
|
||||
const floors = exact.map((c) => Math.floor(c + 1e-9));
|
||||
let remainder = target - floors.reduce((s, f) => s + f, 0);
|
||||
const order = exact.map((c, i) => i).sort((i, j) => (exact[j] - floors[j]) - (exact[i] - floors[i]) || i - j);
|
||||
for (const i of order) { if (remainder <= 0) break; floors[i] += 1; remainder -= 1; }
|
||||
return floors.map((f) => f / 100);
|
||||
}
|
||||
|
||||
/** cart lines -> units, one per item, in cart order. */
|
||||
export function toUnits(lines) {
|
||||
const units = [];
|
||||
for (const l of lines) {
|
||||
for (let i = 0; i < l.quantity; i += 1) {
|
||||
units.push({ lineId: l.id, variantId: l.variantId, productId: l.productId,
|
||||
collectionIds: l.collectionIds || [], price: l.unitPrice });
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
92
engine/function-reference.js
Normal file
92
engine/function-reference.js
Normal file
@ -0,0 +1,92 @@
|
||||
// Reference implementation of the Discount Function in JS (the deployed function is the Rust port in
|
||||
// extensions/bundle-discount). Kept for tests and to document the expected behaviour.
|
||||
import { applyOffers, allocateByLine, largestRemainderCents, toUnits } from './engine.js';
|
||||
import { numericId, offersFromConfig } from './config.js';
|
||||
|
||||
/**
|
||||
* @typedef {import("../generated/api").CartInput} RunInput
|
||||
* @typedef {import("../generated/api").CartLinesDiscountsGenerateRunResult} CartLinesDiscountsGenerateRunResult
|
||||
*/
|
||||
|
||||
const NO_CHANGES = { operations: [] };
|
||||
|
||||
/** Cart lines that may count towards a bundle (F11). */
|
||||
function eligibleLines(input, ex) {
|
||||
const lines = [];
|
||||
for (const line of input.cart.lines) {
|
||||
const m = line.merchandise;
|
||||
if (!m || m.__typename !== 'ProductVariant') continue;
|
||||
if (!(line.quantity >= 1)) continue;
|
||||
const unitPrice = Number(line.cost?.amountPerQuantity?.amount);
|
||||
if (!(unitPrice > 0)) continue;
|
||||
if (m.product.hasAnyTag) continue;
|
||||
if (ex.gc !== false && m.product.isGiftCard) continue;
|
||||
if (ex.subs !== false && line.sellingPlanAllocation) continue;
|
||||
lines.push({
|
||||
id: line.id,
|
||||
variantId: numericId(m.id),
|
||||
productId: numericId(m.product.id),
|
||||
collectionIds: (m.product.inCollections || []).filter((c) => c.isMember).map((c) => numericId(c.collectionId)),
|
||||
unitPrice,
|
||||
quantity: line.quantity,
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates ALL running offers in priority order in one run, so every cart item counts towards at most one offer.
|
||||
* @param {RunInput} input
|
||||
* @returns {CartLinesDiscountsGenerateRunResult}
|
||||
*/
|
||||
export function cartLinesDiscountsGenerateRun(input) {
|
||||
const cfg = input.discount?.metafield?.jsonValue;
|
||||
if (!cfg || cfg.v !== 1 || !Array.isArray(cfg.offers) || !cfg.offers.length) return NO_CHANGES;
|
||||
if (!input.discount.discountClasses.includes('PRODUCT')) return NO_CHANGES;
|
||||
|
||||
const offers = offersFromConfig(cfg, {
|
||||
rate: Number(input.presentmentCurrencyRate) || 1,
|
||||
country: input.localization?.country?.isoCode,
|
||||
pastGuard: input.shop?.localTime?.pastGuard === true,
|
||||
});
|
||||
if (!offers.length) return NO_CHANGES;
|
||||
|
||||
const lines = eligibleLines(input, cfg.ex || {});
|
||||
if (!lines.length) return NO_CHANGES;
|
||||
|
||||
const applications = applyOffers(toUnits(lines), offers);
|
||||
if (!applications.length) return NO_CHANGES;
|
||||
const totals = allocateByLine(applications, { bxgyGetUnitsOnly: cfg.ba === 'get' });
|
||||
|
||||
// Round per offer (largest remainder), then one candidate per cart line. A line can carry units of two
|
||||
// offers (e.g. qty 4 split over two bundles); those amounts are merged so the line gets one fixed amount.
|
||||
const names = new Map((cfg.offers || []).map((o) => [o.id, o.n]));
|
||||
const perLine = new Map();
|
||||
for (const offer of offers) {
|
||||
const items = [];
|
||||
for (const line of lines) {
|
||||
const exact = totals.get(offer.id + '\u0000' + line.id);
|
||||
if (exact !== undefined) items.push([line.id, exact]);
|
||||
}
|
||||
if (!items.length) continue;
|
||||
const rounded = largestRemainderCents(items.map(([, a]) => a));
|
||||
items.forEach(([lineId], i) => {
|
||||
if (!(rounded[i] > 0)) return;
|
||||
const entry = perLine.get(lineId) || { amount: 0, offers: [] };
|
||||
entry.amount += rounded[i];
|
||||
entry.offers.push(offer.id);
|
||||
perLine.set(lineId, entry);
|
||||
});
|
||||
}
|
||||
if (!perLine.size) return NO_CHANGES;
|
||||
|
||||
const candidates = [];
|
||||
for (const [lineId, entry] of perLine) {
|
||||
candidates.push({
|
||||
message: entry.offers.map((id) => names.get(id) || 'Bundle offer').join(' + ').slice(0, 255),
|
||||
targets: [{ cartLine: { id: lineId } }],
|
||||
value: { fixedAmount: { amount: (Math.round(entry.amount * 100) / 100).toFixed(2), appliesToEachItem: false } },
|
||||
});
|
||||
}
|
||||
return { operations: [{ productDiscountsAdd: { candidates, selectionStrategy: 'ALL' } }] };
|
||||
}
|
||||
92
engine/tests/compile.test.js
Normal file
92
engine/tests/compile.test.js
Normal file
@ -0,0 +1,92 @@
|
||||
// The compiler output must match spec/offer-config.schema.json, respect plan limits and running-only, and
|
||||
// produce the expected discounts when fed to the function (JS reference of the Rust function).
|
||||
import { test, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import Ajv from 'ajv/dist/2020.js';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { compile, toShopLocal, NO_GUARD } from '../../app/lib/compile.ts';
|
||||
import { cartLinesDiscountsGenerateRun as run } from '../function-reference.js';
|
||||
|
||||
const schema = JSON.parse(readFileSync(new URL('../../spec/offer-config.schema.json', import.meta.url), 'utf8'));
|
||||
const ajv = new Ajv({ strict: false });
|
||||
addFormats(ajv);
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const now = new Date('2026-10-25T12:00:00Z');
|
||||
let n = 0;
|
||||
function offer(over) {
|
||||
n += 1;
|
||||
return {
|
||||
id: `id${n}`, code: `o${n}`, handle: `offer-${n}`, name: `Offer ${n}`, badge: null, sequence: 10, active: true, published: true,
|
||||
countries: [], type: 'mix', discountType: 'percent', discountValue: 10, mixQty: 2, buyQty: 2, getQty: 1, getPercent: 100,
|
||||
startsAt: null, endsAt: null, maxPerOrder: 0, showHome: true, showProduct: true, showCartHints: true, imageUrl: null,
|
||||
createdAt: new Date(2026, 0, n), lines: [], scopes: [{ role: 'mix', kind: 'collection', refId: '50' }], ...over,
|
||||
};
|
||||
}
|
||||
const shop = (planCode = 'growth', extra = {}) => ({ planCode, currency: 'CAD', timezone: 'America/Toronto', settings: {}, monthOrders: 0, ...extra });
|
||||
|
||||
test('config is valid against the schema and lists running offers in priority order', () => {
|
||||
const offers = [
|
||||
offer({ sequence: 20, name: 'Later' }),
|
||||
offer({ sequence: 5, type: 'fixed', discountType: 'fixed_price', discountValue: 25, scopes: [],
|
||||
lines: [{ productId: '1', variantId: '11', quantity: 1 }, { productId: '2', variantId: null, quantity: 2 }] }),
|
||||
offer({ type: 'bxgy', scopes: [{ role: 'buy', kind: 'product', refId: '7' }, { role: 'get', kind: 'variant', refId: '71', parentId: '7' }] }),
|
||||
offer({ published: false }),
|
||||
offer({ startsAt: new Date('2026-11-01T00:00:00Z') }),
|
||||
offer({ endsAt: new Date('2026-10-01T00:00:00Z') }),
|
||||
];
|
||||
const r = compile(shop(), offers, now);
|
||||
expect(r.error).toBeNull();
|
||||
expect(validate(r.config), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(r.config.offers.map((o) => o.n)).toEqual([offers[1].name, offers[2].name, 'Later']);
|
||||
expect(r.config.offers[0].l).toEqual([{ v: '11', q: 1 }, { p: '2', q: 2 }]);
|
||||
expect(r.nextSyncAt).toEqual(new Date('2026-11-01T00:00:00Z'));
|
||||
expect(r.vars).toEqual({ collectionIds: ['gid://shopify/Collection/50'], excludeTags: ['no-bundle'], guardAt: NO_GUARD });
|
||||
});
|
||||
|
||||
test('tie on sequence: newer start first, then newer offer first (Odoo order)', () => {
|
||||
const a = offer({ name: 'A', startsAt: new Date('2026-10-01T00:00:00Z') });
|
||||
const b = offer({ name: 'B', startsAt: new Date('2026-10-10T00:00:00Z') });
|
||||
const c = offer({ name: 'C' });
|
||||
const d = offer({ name: 'D' });
|
||||
expect(compile(shop(), [a, b, c, d], now).config.offers.map((o) => o.n)).toEqual(['D', 'C', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('plan limits are enforced when compiling', () => {
|
||||
const offers = [offer({ type: 'fixed', scopes: [], lines: [{ productId: '1', variantId: null, quantity: 1 }] }), offer({}), offer({})];
|
||||
const free = compile(shop('free'), offers, now);
|
||||
expect(free.config.offers.length).toBe(1);
|
||||
expect(free.config.offers[0].t).toBe('fixed');
|
||||
expect(Object.keys(free.excluded).length).toBe(2);
|
||||
expect(compile(shop('starter'), offers, now).config.offers.length).toBe(3);
|
||||
const exceeded = compile(shop('free', { monthOrders: 50 }), offers, now);
|
||||
expect(exceeded.config.offers).toEqual([]);
|
||||
});
|
||||
|
||||
test('guard: earliest end, passed to the function in shop local time', () => {
|
||||
const end = new Date('2026-11-09T04:59:59Z');
|
||||
const r = compile(shop(), [offer({ endsAt: end }), offer({ endsAt: new Date('2026-12-01T00:00:00Z') })], now);
|
||||
expect(r.config.g).toBe('2026-11-09T04:59:59Z');
|
||||
expect(r.vars.guardAt).toBe('2026-11-08T23:59:59'); // Toronto, UTC-5 in November
|
||||
expect(toShopLocal(new Date('2026-07-01T12:00:00Z'), 'Asia/Kolkata')).toBe('2026-07-01T17:30:00');
|
||||
});
|
||||
|
||||
test('config size is limited', () => {
|
||||
const many = Array.from({ length: 200 }, () => offer({ name: 'A fairly long festival offer name for the size check' }));
|
||||
const r = compile(shop(), many, now);
|
||||
expect(r.configBytes).toBeGreaterThan(10_000);
|
||||
expect(r.error).toMatch(/Too many running offers/);
|
||||
});
|
||||
|
||||
test('compiled config drives the function end to end', () => {
|
||||
const r = compile(shop(), [offer({ discountType: 'amount', discountValue: 5, mixQty: 2 })], now);
|
||||
const input = {
|
||||
cart: { lines: [1, 2].map((i) => ({ id: `gid://shopify/CartLine/${i}`, quantity: 1, cost: { amountPerQuantity: { amount: '10.0' } },
|
||||
sellingPlanAllocation: null, merchandise: { __typename: 'ProductVariant', id: `gid://shopify/ProductVariant/${i}`,
|
||||
product: { id: `gid://shopify/Product/${i}`, isGiftCard: false, hasAnyTag: false, inCollections: [{ collectionId: 'gid://shopify/Collection/50', isMember: true }] } } })) },
|
||||
localization: { country: { isoCode: 'CA' } }, presentmentCurrencyRate: '1.0', shop: { localTime: { pastGuard: false } },
|
||||
discount: { discountClasses: ['PRODUCT'], metafield: { jsonValue: r.config } },
|
||||
};
|
||||
const out = run(input).operations[0].productDiscountsAdd.candidates.map((c) => c.value.fixedAmount.amount);
|
||||
expect(out).toEqual(['2.50', '2.50']);
|
||||
});
|
||||
49
engine/tests/differential.test.js
Normal file
49
engine/tests/differential.test.js
Normal file
@ -0,0 +1,49 @@
|
||||
// The optimised engine must give exactly the same applications as the reference port (which passes the
|
||||
// Odoo vectors) on random carts and offers, including ties, overlaps, zero-discount uses and max uses.
|
||||
import { test, expect } from 'vitest';
|
||||
import * as fast from '../engine.js';
|
||||
import * as ref from './reference/bundle-engine.reference.js';
|
||||
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 2 ** 32; };
|
||||
}
|
||||
|
||||
function randomCase(r) {
|
||||
const pick = (n) => Math.floor(r() * n);
|
||||
const nLines = 1 + pick(8);
|
||||
const lines = Array.from({ length: nLines }, (_, i) => ({
|
||||
id: `l${i}`, variantId: `v${pick(6)}`, productId: `p${pick(4)}`,
|
||||
collectionIds: [`c${pick(3)}`, ...(r() < 0.3 ? [`c${pick(3)}`] : [])],
|
||||
unitPrice: [1, 2, 2, 5, 7.5, 10, 10, 19.99][pick(8)], quantity: 1 + pick(4),
|
||||
}));
|
||||
const scope = () => ({ variantIds: new Set(r() < 0.3 ? [`v${pick(6)}`] : []),
|
||||
productIds: new Set(r() < 0.4 ? [`p${pick(4)}`] : []), collectionIds: new Set(r() < 0.7 ? [`c${pick(3)}`] : []) });
|
||||
const dts = ['percent', 'amount', 'fixed_price'];
|
||||
const offers = Array.from({ length: 1 + pick(4) }, (_, i) => {
|
||||
const type = ['fixed', 'mix', 'bxgy'][pick(3)];
|
||||
const o = { id: `o${i}`, type, maxUses: pick(3), discountType: dts[pick(3)], value: [0, 1, 5, 10, 20, 50, 100][pick(7)] };
|
||||
if (type === 'fixed') o.lines = Array.from({ length: 1 + pick(3) }, () => (r() < 0.5
|
||||
? { variantId: `v${pick(6)}`, quantity: 1 + pick(2) } : { productId: `p${pick(4)}`, quantity: 1 + pick(2) }));
|
||||
if (type === 'mix') { o.qty = 1 + pick(4); o.scope = scope(); }
|
||||
if (type === 'bxgy') { o.buyQty = 1 + pick(3); o.getQty = 1 + pick(2); o.getPercent = [0, 50, 100][pick(3)];
|
||||
o.buyScope = scope(); o.getScope = scope(); }
|
||||
return o;
|
||||
});
|
||||
return { lines, offers };
|
||||
}
|
||||
|
||||
const summary = (apps) => apps.map((a) => ({ o: a.offerId, d: a.discount, u: a.units.map((u) => `${u.lineId}:${u.price}`) }));
|
||||
|
||||
test('optimised engine == reference engine on 5000 random cases', () => {
|
||||
const r = rng(20261013);
|
||||
for (let i = 0; i < 5000; i += 1) {
|
||||
const { lines, offers } = randomCase(r);
|
||||
const a = fast.applyOffers(fast.toUnits(lines), offers);
|
||||
const b = ref.applyOffers(ref.toUnits(lines), offers);
|
||||
expect(summary(a), `case ${i}`).toEqual(summary(b));
|
||||
const ta = [...fast.allocateByLine(a)].map(([k, v]) => [k, v.toFixed(9)]);
|
||||
const tb = [...ref.allocateByLine(b)].map(([k, v]) => [k, v.toFixed(9)]);
|
||||
expect(ta, `allocation case ${i}`).toEqual(tb);
|
||||
}
|
||||
});
|
||||
137
engine/tests/reference/bundle-engine.reference.js
Normal file
137
engine/tests/reference/bundle-engine.reference.js
Normal file
@ -0,0 +1,137 @@
|
||||
// Reference JavaScript port of the Odoo bundle engine
|
||||
// (reference/odoo-c2c_bundle_offers/models/bundle_engine.py).
|
||||
//
|
||||
// Pure functions, no Shopify imports: drop this into the Discount Function (JavaScript) or port it
|
||||
// line by line to Rust. It must keep passing spec/test-vectors.json (run: node run-vectors.mjs).
|
||||
//
|
||||
// Behaviour that MUST be preserved (it decides which items are discounted):
|
||||
// * One unit per item; pool order = cart line order, then unit order.
|
||||
// * Offers are tried in priority order; every unit an offer uses leaves the pool.
|
||||
// * An offer repeats while it still matches, up to maxUses (0 = unlimited).
|
||||
// * _take sorts matching units by price with a STABLE sort (ties keep pool order).
|
||||
// * fixed: per bundle line, cheapest matching units first; all-or-nothing (pool restored on failure).
|
||||
// * mix: cheapest matching units first.
|
||||
// * bxgy: "buy" = MOST expensive matching units, "get" = cheapest remaining matching units.
|
||||
// * A use that gives 0 discount puts its units back at the END of the pool and stops that offer.
|
||||
// * The discount of a use is spread over ALL its units in proportion to price (bxgy included).
|
||||
|
||||
export function bundleDiscount(regular, discountType, value) {
|
||||
regular = Math.max(regular, 0);
|
||||
value = Math.max(value || 0, 0);
|
||||
if (discountType === 'percent') return (regular * Math.min(value, 100)) / 100;
|
||||
if (discountType === 'amount') return Math.min(value, regular);
|
||||
if (discountType === 'fixed_price') return Math.max(regular - value, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function matches(unit, scope) {
|
||||
// scope = { variantIds: Set, productIds: Set, collectionIds: Set }
|
||||
if (scope.variantIds && scope.variantIds.has(unit.variantId)) return true;
|
||||
if (scope.productIds && scope.productIds.has(unit.productId)) return true;
|
||||
if (scope.collectionIds) for (const c of unit.collectionIds) if (scope.collectionIds.has(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function take(pool, predicate, qty, cheapestFirst = true) {
|
||||
const found = pool.filter(predicate); // keeps pool order
|
||||
found.sort((a, b) => (cheapestFirst ? a.price - b.price : b.price - a.price)); // stable (ES2019+)
|
||||
if (found.length < qty) return null;
|
||||
const taken = found.slice(0, qty);
|
||||
for (const unit of taken) pool.splice(pool.indexOf(unit), 1);
|
||||
return taken;
|
||||
}
|
||||
|
||||
function applyOnce(pool, offer) {
|
||||
let used;
|
||||
let discount;
|
||||
if (offer.type === 'fixed') {
|
||||
const lines = (offer.lines || []).filter((l) => l.quantity > 0);
|
||||
if (!lines.length) return null;
|
||||
const backup = pool.slice();
|
||||
used = [];
|
||||
for (const line of lines) {
|
||||
const pred = line.variantId
|
||||
? (u) => u.variantId === line.variantId
|
||||
: (u) => u.productId === line.productId; // product-level line: any variant
|
||||
const taken = take(pool, pred, line.quantity);
|
||||
if (!taken) { pool.splice(0, pool.length, ...backup); return null; }
|
||||
used.push(...taken);
|
||||
}
|
||||
discount = bundleDiscount(sum(used), offer.discountType, offer.value);
|
||||
} else if (offer.type === 'mix') {
|
||||
if (!(offer.qty > 0)) return null;
|
||||
used = take(pool, (u) => matches(u, offer.scope), offer.qty, true);
|
||||
if (!used) return null;
|
||||
discount = bundleDiscount(sum(used), offer.discountType, offer.value);
|
||||
} else if (offer.type === 'bxgy') {
|
||||
if (!(offer.buyQty > 0) || !(offer.getQty > 0)) return null;
|
||||
const backup = pool.slice();
|
||||
const bought = take(pool, (u) => matches(u, offer.buyScope), offer.buyQty, false);
|
||||
if (!bought) return null;
|
||||
const got = take(pool, (u) => matches(u, offer.getScope), offer.getQty, true);
|
||||
if (!got) { pool.splice(0, pool.length, ...backup); return null; }
|
||||
used = bought.concat(got);
|
||||
const pct = Math.min(Math.max(offer.getPercent || 0, 0), 100);
|
||||
discount = (sum(got) * pct) / 100;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (discount <= 0) { pool.push(...used); return null; }
|
||||
return { offerId: offer.id, discount, units: used };
|
||||
}
|
||||
|
||||
function sum(units) { return units.reduce((s, u) => s + u.price, 0); }
|
||||
|
||||
/** units: [{lineId, variantId, productId, collectionIds: string[], price}] ; offers in priority order */
|
||||
export function applyOffers(units, offers) {
|
||||
const pool = units.slice();
|
||||
const applications = [];
|
||||
for (const offer of offers) {
|
||||
let uses = 0;
|
||||
const maxUses = offer.maxUses || 0;
|
||||
while (!maxUses || uses < maxUses) {
|
||||
const app = applyOnce(pool, offer);
|
||||
if (!app) break;
|
||||
applications.push(app);
|
||||
uses += 1;
|
||||
}
|
||||
}
|
||||
return applications;
|
||||
}
|
||||
|
||||
/** Discount per (offerId, lineId), spread by price exactly like the Odoo split_by(). */
|
||||
export function allocateByLine(applications) {
|
||||
const totals = new Map();
|
||||
for (const app of applications) {
|
||||
const base = sum(app.units);
|
||||
for (const unit of app.units) {
|
||||
const share = base ? app.discount * (unit.price / base) : app.discount / app.units.length;
|
||||
const key = app.offerId + '\u0000' + unit.lineId;
|
||||
totals.set(key, (totals.get(key) || 0) + share);
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Round amounts to cents so they add up to round(total): largest remainder, ties by input order. */
|
||||
export function largestRemainderCents(amounts) {
|
||||
const exact = amounts.map((a) => a * 100);
|
||||
const target = Math.round(amounts.reduce((s, a) => s + a, 0) * 100 + 1e-9);
|
||||
const floors = exact.map((c) => Math.floor(c + 1e-9));
|
||||
let remainder = target - floors.reduce((s, f) => s + f, 0);
|
||||
const order = exact.map((c, i) => i).sort((i, j) => (exact[j] - floors[j]) - (exact[i] - floors[i]) || i - j);
|
||||
for (const i of order) { if (remainder <= 0) break; floors[i] += 1; remainder -= 1; }
|
||||
return floors.map((f) => f / 100);
|
||||
}
|
||||
|
||||
/** cart lines -> units, one per item, in cart order. */
|
||||
export function toUnits(lines) {
|
||||
const units = [];
|
||||
for (const l of lines) {
|
||||
for (let i = 0; i < l.quantity; i += 1) {
|
||||
units.push({ lineId: l.id, variantId: l.variantId, productId: l.productId,
|
||||
collectionIds: l.collectionIds || [], price: l.unitPrice });
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
53
engine/tests/run-vectors.mjs
Normal file
53
engine/tests/run-vectors.mjs
Normal file
@ -0,0 +1,53 @@
|
||||
// Checks the JS engine port against spec/test-vectors.json (generated from the Odoo engine).
|
||||
// Run: node engine/tests/run-vectors.mjs (also part of npm test)
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { applyOffers, allocateByLine, largestRemainderCents, toUnits } from '../engine.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const { vectors } = JSON.parse(readFileSync(join(here, '..', '..', 'spec', 'test-vectors.json'), 'utf8'));
|
||||
|
||||
const scope = (s) => ({ variantIds: new Set(s.variant_ids || []), productIds: new Set(s.product_ids || []),
|
||||
collectionIds: new Set(s.collection_ids || []) });
|
||||
const toOffer = (o) => ({
|
||||
id: o.id, type: o.type, maxUses: o.max_uses || 0, discountType: o.discount_type, value: o.value,
|
||||
lines: (o.lines || []).map((l) => ({ variantId: l.variant_id, productId: l.product_id, quantity: l.quantity })),
|
||||
qty: o.qty, scope: o.scope && scope(o.scope),
|
||||
buyQty: o.buy_qty, getQty: o.get_qty, getPercent: o.get_percent,
|
||||
buyScope: o.buy_scope && scope(o.buy_scope), getScope: o.get_scope && scope(o.get_scope),
|
||||
});
|
||||
const close = (a, b) => Math.abs(a - b) < 1e-6;
|
||||
|
||||
let failed = 0;
|
||||
for (const v of vectors) {
|
||||
const lines = v.cart.lines.map((l) => ({ id: l.id, variantId: l.variant_id, productId: l.product_id,
|
||||
collectionIds: l.collection_ids, unitPrice: l.unit_price, quantity: l.quantity }));
|
||||
const apps = applyOffers(toUnits(lines), v.offers.map(toOffer));
|
||||
const totals = allocateByLine(apps);
|
||||
const allocations = [];
|
||||
for (const o of v.offers) {
|
||||
const items = lines.map((l) => [l.id, totals.get(o.id + '\u0000' + l.id)]).filter(([, a]) => a !== undefined);
|
||||
const rounded = largestRemainderCents(items.map(([, a]) => a));
|
||||
items.forEach(([lineId, a], i) => allocations.push({ offer_id: o.id, line_id: lineId, amount_exact: a, amount: rounded[i] }));
|
||||
}
|
||||
const exp = v.expected;
|
||||
const errors = [];
|
||||
if (apps.length !== exp.applications.length) errors.push(`applications ${apps.length} != ${exp.applications.length}`);
|
||||
apps.forEach((a, i) => {
|
||||
const e = exp.applications[i]; if (!e) return;
|
||||
if (a.offerId !== e.offer_id || !close(a.discount, e.discount)) errors.push(`app ${i}: ${a.offerId}/${a.discount} != ${e.offer_id}/${e.discount}`);
|
||||
const used = a.units.map((u) => u.lineId);
|
||||
if (JSON.stringify(used) !== JSON.stringify(e.line_ids_used)) errors.push(`app ${i} units ${used} != ${e.line_ids_used}`);
|
||||
});
|
||||
if (allocations.length !== exp.allocations.length) errors.push(`allocations ${allocations.length} != ${exp.allocations.length}`);
|
||||
allocations.forEach((a, i) => {
|
||||
const e = exp.allocations[i]; if (!e) return;
|
||||
if (a.offer_id !== e.offer_id || a.line_id !== e.line_id || !close(a.amount_exact, e.amount_exact) || !close(a.amount, e.amount))
|
||||
errors.push(`alloc ${i}: ${JSON.stringify(a)} != ${JSON.stringify(e)}`);
|
||||
});
|
||||
if (errors.length) { failed += 1; console.log(`FAIL ${v.name}\n ` + errors.join('\n ')); }
|
||||
else console.log(`ok ${v.name}`);
|
||||
}
|
||||
console.log(failed ? `\n${failed} of ${vectors.length} failed` : `\nall ${vectors.length} vectors pass`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
162
engine/tests/run.test.js
Normal file
162
engine/tests/run.test.js
Normal file
@ -0,0 +1,162 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { cartLinesDiscountsGenerateRun as run } from '../function-reference.js';
|
||||
|
||||
const P = (n) => `gid://shopify/Product/${n}`;
|
||||
const V = (n) => `gid://shopify/ProductVariant/${n}`;
|
||||
const C = (n) => `gid://shopify/Collection/${n}`;
|
||||
|
||||
function line(id, { v, p, price, qty = 1, cols = [], tag = false, gc = false, sub = false }) {
|
||||
return {
|
||||
id: `gid://shopify/CartLine/${id}`,
|
||||
quantity: qty,
|
||||
cost: { amountPerQuantity: { amount: String(price) } },
|
||||
sellingPlanAllocation: sub ? { sellingPlan: { id: 'gid://shopify/SellingPlan/1' } } : null,
|
||||
merchandise: {
|
||||
__typename: 'ProductVariant',
|
||||
id: V(v),
|
||||
product: { id: P(p), isGiftCard: gc, hasAnyTag: tag,
|
||||
inCollections: cols.map((c) => ({ collectionId: C(c), isMember: true })) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function input(lines, config, extra = {}) {
|
||||
return {
|
||||
cart: { lines },
|
||||
localization: { country: { isoCode: extra.country || 'CA' } },
|
||||
presentmentCurrencyRate: extra.rate ?? '1.0',
|
||||
shop: { localTime: { pastGuard: extra.pastGuard ?? false } },
|
||||
discount: { discountClasses: ['PRODUCT'], metafield: config === null ? null : { jsonValue: { v: 1, ex: { subs: true, gc: true }, ...config } } },
|
||||
};
|
||||
}
|
||||
|
||||
const amounts = (result) => Object.fromEntries(
|
||||
(result.operations[0]?.productDiscountsAdd.candidates || []).map((c) => [c.targets[0].cartLine.id.split('/').pop(), c.value.fixedAmount.amount]));
|
||||
const messages = (result) => (result.operations[0]?.productDiscountsAdd.candidates || []).map((c) => c.message);
|
||||
|
||||
const fixed20 = { id: 'o1', n: 'Puja Kit', t: 'fixed', dt: 'percent', dv: 20, l: [{ v: '11', q: 1 }, { p: '2', q: 2 }] };
|
||||
const mix3for20 = { id: 'o2', n: 'Any 3 Sweets for $20', t: 'mix', dt: 'fixed_price', dv: 20, q: 3, sc: { c: ['50'] } };
|
||||
const b2g1 = { id: 'o3', n: 'Buy 2 Get 1', t: 'bxgy', bq: 2, gq: 1, gp: 100, bs: { c: ['60'] }, gs: { c: ['60'] } };
|
||||
|
||||
describe('bundle discount function', () => {
|
||||
test('no configuration -> no operations', () => {
|
||||
expect(run(input([line(1, { v: 11, p: 1, price: 10 })], null))).toEqual({ operations: [] });
|
||||
});
|
||||
|
||||
test('fixed bundle: percent off spread over lines, product-level line takes any variant', () => {
|
||||
const r = run(input([line(1, { v: 11, p: 1, price: 10 }), line(2, { v: 21, p: 2, price: 5, qty: 2 })], { offers: [fixed20] }));
|
||||
expect(amounts(r)).toEqual({ 1: '2.00', 2: '2.00' });
|
||||
expect(messages(r)).toEqual(['Puja Kit', 'Puja Kit']);
|
||||
expect(r.operations[0].productDiscountsAdd.selectionStrategy).toBe('ALL');
|
||||
});
|
||||
|
||||
test('fixed bundle incomplete -> nothing', () => {
|
||||
const r = run(input([line(1, { v: 11, p: 1, price: 10 }), line(2, { v: 21, p: 2, price: 5 })], { offers: [fixed20] }));
|
||||
expect(r.operations).toEqual([]);
|
||||
});
|
||||
|
||||
test('mix & match uses cheapest matching items, bundle price', () => {
|
||||
const r = run(input([
|
||||
line(1, { v: 1, p: 1, price: 12, cols: [50] }),
|
||||
line(2, { v: 2, p: 2, price: 8, qty: 2, cols: [50] }),
|
||||
line(3, { v: 3, p: 3, price: 9, cols: [50] }),
|
||||
], { offers: [mix3for20] }));
|
||||
// cheapest 3 = 8 + 8 + 9 = 25 -> price 20 -> 5 off, spread 1.60/1.60/1.80
|
||||
expect(amounts(r)).toEqual({ 2: '3.20', 3: '1.80' });
|
||||
});
|
||||
|
||||
test('bxgy: most expensive are "buy", cheapest remaining is "get" (parity allocation over all units)', () => {
|
||||
const r = run(input([
|
||||
line(1, { v: 1, p: 1, price: 30, cols: [60] }),
|
||||
line(2, { v: 2, p: 2, price: 20, cols: [60] }),
|
||||
line(3, { v: 3, p: 3, price: 10, cols: [60] }),
|
||||
], { offers: [b2g1] }));
|
||||
// discount 10 spread over 60 -> 5 / 3.33 / 1.67
|
||||
expect(amounts(r)).toEqual({ 1: '5.00', 2: '3.33', 3: '1.67' });
|
||||
});
|
||||
|
||||
test('bxgy get-units-only allocation setting', () => {
|
||||
const r = run(input([
|
||||
line(1, { v: 1, p: 1, price: 30, cols: [60] }),
|
||||
line(2, { v: 2, p: 2, price: 20, cols: [60] }),
|
||||
line(3, { v: 3, p: 3, price: 10, cols: [60] }),
|
||||
], { ba: 'get', offers: [b2g1] }));
|
||||
expect(amounts(r)).toEqual({ 3: '10.00' });
|
||||
});
|
||||
|
||||
test('priority + exclusivity: an item counts towards one offer only', () => {
|
||||
const both = { id: 'a', n: 'First', t: 'mix', dt: 'percent', dv: 10, q: 2, sc: { c: ['50'] } };
|
||||
const second = { id: 'b', n: 'Second', t: 'mix', dt: 'percent', dv: 50, q: 2, sc: { c: ['50'] } };
|
||||
const r = run(input([line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })], { offers: [both, second] }));
|
||||
// first offer takes 2 units (2.00), one unit left -> second can't apply
|
||||
expect(amounts(r)).toEqual({ 1: '2.00' });
|
||||
expect(messages(r)).toEqual(['First']);
|
||||
});
|
||||
|
||||
test('two offers on one line are merged into one candidate', () => {
|
||||
const a = { id: 'a', n: 'A', t: 'mix', dt: 'amount', dv: 1, q: 1, mu: 1, sc: { p: ['1'] } };
|
||||
const b = { id: 'b', n: 'B', t: 'mix', dt: 'amount', dv: 2, q: 1, mu: 1, sc: { p: ['1'] } };
|
||||
const r = run(input([line(1, { v: 1, p: 1, price: 10, qty: 2 })], { offers: [a, b] }));
|
||||
expect(amounts(r)).toEqual({ 1: '3.00' });
|
||||
expect(messages(r)).toEqual(['A + B']);
|
||||
});
|
||||
|
||||
test('repeats while possible, max uses caps it', () => {
|
||||
const lines = [line(1, { v: 1, p: 1, price: 10, qty: 7, cols: [50] })];
|
||||
const unlimited = run(input(lines, { offers: [{ ...mix3for20, dt: 'amount', dv: 5 }] }));
|
||||
expect(amounts(unlimited)).toEqual({ 1: '10.00' }); // 2 uses
|
||||
const capped = run(input(lines, { offers: [{ ...mix3for20, dt: 'amount', dv: 5, mu: 1 }] }));
|
||||
expect(amounts(capped)).toEqual({ 1: '5.00' });
|
||||
});
|
||||
|
||||
test('amount and fixed price are converted to the presentment currency', () => {
|
||||
const r = run(input([line(1, { v: 1, p: 1, price: 15, qty: 3, cols: [50] })], { offers: [mix3for20] }, { rate: '1.5' }));
|
||||
// regular 45, bundle price 20 * 1.5 = 30 -> 15 off
|
||||
expect(amounts(r)).toEqual({ 1: '15.00' });
|
||||
});
|
||||
|
||||
test('exclusions: tag, gift card, subscription, zero price', () => {
|
||||
const offers = [{ id: 'm', n: 'M', t: 'mix', dt: 'percent', dv: 10, q: 1, sc: { p: ['1'] } }];
|
||||
const r = run(input([
|
||||
line(1, { v: 1, p: 1, price: 10, tag: true }),
|
||||
line(2, { v: 2, p: 1, price: 10, gc: true }),
|
||||
line(3, { v: 3, p: 1, price: 10, sub: true }),
|
||||
line(4, { v: 4, p: 1, price: 0 }),
|
||||
line(5, { v: 5, p: 1, price: 10 }),
|
||||
], { offers }));
|
||||
expect(amounts(r)).toEqual({ 5: '1.00' });
|
||||
});
|
||||
|
||||
test('country targeting', () => {
|
||||
const offers = [{ ...mix3for20, cc: ['US'] }];
|
||||
const lines = [line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })];
|
||||
expect(run(input(lines, { offers }, { country: 'CA' })).operations).toEqual([]);
|
||||
expect(amounts(run(input(lines, { offers }, { country: 'US' })))).toEqual({ 1: '10.00' });
|
||||
});
|
||||
|
||||
test('schedule guard: once the guard time passed, offers ending by then are dropped', () => {
|
||||
const g = '2026-11-09T04:59:59Z';
|
||||
const offers = [{ ...mix3for20, e: g }, { ...b2g1, id: 'o4' }];
|
||||
const lines = [line(1, { v: 1, p: 1, price: 10, qty: 3, cols: [50] })];
|
||||
expect(amounts(run(input(lines, { g, offers }, { pastGuard: false })))).toEqual({ 1: '10.00' });
|
||||
expect(run(input(lines, { g, offers }, { pastGuard: true })).operations).toEqual([]);
|
||||
});
|
||||
|
||||
test('large cart: 200 lines x 5 qty with 50 offers stays fast', () => {
|
||||
const lines = Array.from({ length: 200 }, (_, i) => line(i + 1, { v: i + 1, p: i + 1, price: 1 + (i % 37), qty: 5, cols: [i % 10] }));
|
||||
const offers = Array.from({ length: 50 }, (_, i) => ({ id: `x${i}`, n: `Offer ${i}`, t: i % 2 ? 'mix' : 'bxgy',
|
||||
dt: 'percent', dv: 10, q: 3, sc: { c: [String(i % 10)] }, bq: 2, gq: 1, gp: 100, bs: { c: [String(i % 10)] }, gs: { c: [String(i % 10)] } }));
|
||||
const t0 = performance.now();
|
||||
const r = run(input(lines, { offers }));
|
||||
expect(performance.now() - t0).toBeLessThan(500);
|
||||
expect(r.operations[0].productDiscountsAdd.candidates.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('engine parity vectors (spec/test-vectors.json)', () => {
|
||||
const script = fileURLToPath(new URL('./run-vectors.mjs', import.meta.url));
|
||||
const out = execFileSync(process.execPath, [script], { encoding: 'utf8' });
|
||||
expect(out).toContain('all 19 vectors pass');
|
||||
});
|
||||
36
engine/tests/storefront.test.js
Normal file
36
engine/tests/storefront.test.js
Normal file
@ -0,0 +1,36 @@
|
||||
// Proxy page Liquid: merchant text can't inject Liquid/HTML; every bundle type renders; writes the output to
|
||||
// $BO_LIQUID_OUT (if set) so it can be run through theme-check.
|
||||
import { test, expect } from 'vitest';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { bundlePageLiquid, bundlesListLiquid, esc } from '../../app/lib/storefront.server.ts';
|
||||
|
||||
const base = { id: 'x', code: 'o1', handle: 'diwali-kit', oldHandles: [], name: 'Diwali {{ shop.name }} <b>Kit</b>', badge: 'Diwali {% raw %}', description: 'Line 1\nLine 2', i18n: { fr: { name: 'Coffret Diwali' } }, sequence: 10, active: true, published: true, countries: [],
|
||||
discountType: 'percent', discountValue: 20, mixQty: 3, buyQty: 2, getQty: 1, getPercent: 100, startsAt: null, endsAt: new Date('2026-11-09T04:59:59Z'), maxPerOrder: 0,
|
||||
showHome: true, showProduct: true, showCartHints: true, imageUrl: null, limitedByPlan: false, createdAt: new Date(), updatedAt: new Date(), shopId: 's',
|
||||
lines: [{ productId: '1', variantId: '11', quantity: 1, handle: 'diya', title: 'Diya', imageUrl: 'https://cdn.shopify.com/a.jpg' }, { productId: '2', variantId: null, quantity: 2, handle: "camphor'}}", title: 'Camphor', imageUrl: null }],
|
||||
scopes: [{ role: 'mix', kind: 'collection', refId: '50', handle: 'sweets' }, { role: 'mix', kind: 'variant', refId: '71', parentId: '7', handle: 'ladoo' },
|
||||
{ role: 'buy', kind: 'collection', refId: '60', handle: 'garlands' }, { role: 'get', kind: 'product', refId: '8', handle: 'marigold' }] };
|
||||
const shop = { currency: 'CAD', timezone: 'America/Toronto', branding: true };
|
||||
|
||||
test('escaping neutralises Liquid and HTML', () => {
|
||||
expect(esc('{{ x }} {% y %} <a>')).not.toMatch(/[{}%<>]/);
|
||||
});
|
||||
|
||||
test('bundle pages render for every type without merchant-controlled Liquid', () => {
|
||||
const out = process.env.BO_LIQUID_OUT;
|
||||
if (out) mkdirSync(join(out, 'snippets'), { recursive: true });
|
||||
for (const type of ['fixed', 'mix', 'bxgy']) {
|
||||
const liquid = bundlePageLiquid({ ...base, type }, shop, null);
|
||||
expect(liquid).not.toContain('{{ shop.name');
|
||||
expect(liquid).not.toContain('{% raw');
|
||||
expect(liquid).not.toContain("camphor'}}");
|
||||
if (type === 'fixed') expect(liquid).toContain("all_products['camphor']");
|
||||
expect(liquid).toContain(type === 'fixed' ? 'Add bundle to cart' : 'Add to cart');
|
||||
if (out) writeFileSync(join(out, 'snippets', `page-${type}.liquid`), liquid);
|
||||
}
|
||||
expect(bundlePageLiquid({ ...base, type: 'mix' }, shop, 'fr')).toContain('Coffret Diwali');
|
||||
const list = bundlesListLiquid([{ ...base, type: 'fixed' }, { ...base, type: 'mix', discountType: 'fixed_price' }], shop, null);
|
||||
expect(list).toContain('data-bo-money="20"');
|
||||
if (out) writeFileSync(join(out, 'snippets', 'list.liquid'), list);
|
||||
});
|
||||
2
engine/vitest.config.js
Normal file
2
engine/vitest.config.js
Normal file
@ -0,0 +1,2 @@
|
||||
// Plain Node tests for the shared engine (keeps the React Router vite plugin out).
|
||||
export default { test: { include: ['tests/**/*.test.js'] } };
|
||||
2
env.d.ts
vendored
Normal file
2
env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="@react-router/node" />
|
||||
0
extensions/.gitkeep
Normal file
0
extensions/.gitkeep
Normal file
1
extensions/bundle-discount/.gitignore
vendored
Normal file
1
extensions/bundle-discount/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
||||
484
extensions/bundle-discount/Cargo.lock
generated
Normal file
484
extensions/bundle-discount/Cargo.lock
generated
Normal file
@ -0,0 +1,484 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ariadne"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36f5e3dca4e09a6f340a61a0e9c7b61e030c69fc27bf29d73218f7e5e3b7638f"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "beef"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
|
||||
|
||||
[[package]]
|
||||
name = "bluejay-core"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5860a442543063d03e6f76795c268ea5679e91fac3500f10dba2feeb85cf1818"
|
||||
dependencies = [
|
||||
"enum-as-inner",
|
||||
"itertools",
|
||||
"serde_json",
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bluejay-parser"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e525e5fad12f700f107135151cb957f9e1ff8044cb52ba7562b76e6be2d34e5"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"bluejay-core",
|
||||
"enum-as-inner",
|
||||
"itertools",
|
||||
"logos",
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bluejay-typegen-codegen"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02e8869859a2a7381c11caf6441b83813082c36275e7daf183c94b4a0ecdc30d"
|
||||
dependencies = [
|
||||
"bluejay-core",
|
||||
"bluejay-parser",
|
||||
"bluejay-validator",
|
||||
"convert_case 0.8.0",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bluejay-validator"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde85073f8756b2fa6bf18b52eef7d3b42414bd087bb75eb5ea0e4d9a2d10d95"
|
||||
dependencies = [
|
||||
"bluejay-core",
|
||||
"bluejay-parser",
|
||||
"itertools",
|
||||
"paste",
|
||||
"seq-macro",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bundle-discount"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"shopify_function",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
|
||||
dependencies = [
|
||||
"logos-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-codegen"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
|
||||
dependencies = [
|
||||
"beef",
|
||||
"fnv",
|
||||
"lazy_static",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex-syntax",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-derive"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
|
||||
dependencies = [
|
||||
"logos-codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp-serde"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
|
||||
dependencies = [
|
||||
"rmp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "seq-macro"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shopify_function"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e857e31fd15c8639ac5e33768f5f6b2832495983e75021717f6ea4dbde2d892a"
|
||||
dependencies = [
|
||||
"ryu",
|
||||
"serde_json",
|
||||
"shopify_function_macro",
|
||||
"shopify_function_wasm_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shopify_function_macro"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03908a59e327bf35fce1dfd8f8d1d814f87b4a583039645e06ac5392758a9e41"
|
||||
dependencies = [
|
||||
"bluejay-core",
|
||||
"bluejay-typegen-codegen",
|
||||
"convert_case 0.11.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shopify_function_provider"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb8178e79dd651ca3e1474ab942265fb4044a73c28b1d05687519c6ac65637c2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"rmp",
|
||||
"shopify_function_wasm_api_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shopify_function_wasm_api"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42fffecf0421a95118df56563785a1703fa5ed940b26c75ac7990c72f6ce3c47"
|
||||
dependencies = [
|
||||
"rmp-serde",
|
||||
"seq-macro",
|
||||
"serde_json",
|
||||
"shopify_function_provider",
|
||||
"shopify_function_wasm_api_core",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shopify_function_wasm_api_core"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7367c758d2ec5644f78f662e3748564dd71c50462ad7eec679f0af139984948"
|
||||
dependencies = [
|
||||
"strum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
15
extensions/bundle-discount/Cargo.toml
Normal file
15
extensions/bundle-discount/Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "bundle-discount"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
shopify_function = "2.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
4
extensions/bundle-discount/locales/en.default.json
Normal file
4
extensions/bundle-discount/locales/en.default.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Bundle Offers",
|
||||
"description": "Applies fixed, mix & match and buy X get Y bundle discounts. Each item counts towards one offer, in priority order."
|
||||
}
|
||||
9
extensions/bundle-discount/package.json
Normal file
9
extensions/bundle-discount/package.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "bundle-discount",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "cargo test"
|
||||
}
|
||||
}
|
||||
5989
extensions/bundle-discount/schema.graphql
Normal file
5989
extensions/bundle-discount/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
21
extensions/bundle-discount/shopify.extension.toml
Normal file
21
extensions/bundle-discount/shopify.extension.toml
Normal file
@ -0,0 +1,21 @@
|
||||
# Bundle Offers: ONE automatic app discount per shop runs this function, which evaluates every running offer.
|
||||
# Rust (not JS): the JS build needed 39M instructions just to read a 200-line cart (limit 11M). See README.
|
||||
api_version = "2026-07"
|
||||
|
||||
[[extensions]]
|
||||
name = "t:name"
|
||||
handle = "bundle-discount"
|
||||
type = "function"
|
||||
description = "t:description"
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "cart.lines.discounts.generate.run"
|
||||
input_query = "src/cart_lines_discounts_generate_run.graphql"
|
||||
export = "cart_lines_discounts_generate_run"
|
||||
# Collection ids, exclusion tags and the guard time come from this discount metafield (written by the app).
|
||||
input_variables = { namespace = "$app", key = "function-vars" }
|
||||
|
||||
[extensions.build]
|
||||
command = "cargo build --target=wasm32-unknown-unknown --release"
|
||||
path = "target/wasm32-unknown-unknown/release/bundle-discount.wasm"
|
||||
watch = ["src/**/*.rs"]
|
||||
@ -0,0 +1,54 @@
|
||||
query Input(
|
||||
$collectionIds: [ID!]!
|
||||
$excludeTags: [String!]!
|
||||
$guardAt: DateTimeWithoutTimezone!
|
||||
) {
|
||||
cart {
|
||||
lines {
|
||||
id
|
||||
quantity
|
||||
cost {
|
||||
amountPerQuantity {
|
||||
amount
|
||||
}
|
||||
}
|
||||
sellingPlanAllocation {
|
||||
sellingPlan {
|
||||
id
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
__typename
|
||||
... on ProductVariant {
|
||||
id
|
||||
product {
|
||||
id
|
||||
isGiftCard
|
||||
hasAnyTag(tags: $excludeTags)
|
||||
inCollections(ids: $collectionIds) {
|
||||
collectionId
|
||||
isMember
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
localization {
|
||||
country {
|
||||
isoCode
|
||||
}
|
||||
}
|
||||
presentmentCurrencyRate
|
||||
shop {
|
||||
localTime {
|
||||
pastGuard: dateTimeAfter(dateTime: $guardAt)
|
||||
}
|
||||
}
|
||||
discount {
|
||||
discountClasses
|
||||
metafield(namespace: "$app", key: "configuration") {
|
||||
jsonValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
use super::schema;
|
||||
use shopify_function::prelude::*;
|
||||
use shopify_function::Result;
|
||||
|
||||
use crate::config::{offers_for, Context};
|
||||
use crate::engine::{allocate_by_line, apply_offers, largest_remainder_cents, Line};
|
||||
use schema::cart_lines_discounts_generate_run::input::cart::lines::Merchandise;
|
||||
|
||||
fn numeric_id(gid: &str) -> &str {
|
||||
match gid.rfind('/') {
|
||||
Some(i) => &gid[i + 1..],
|
||||
None => gid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates ALL running offers in priority order in one run, so every cart item counts towards at most one offer.
|
||||
#[shopify_function]
|
||||
pub fn cart_lines_discounts_generate_run(
|
||||
input: schema::cart_lines_discounts_generate_run::Input,
|
||||
) -> Result<schema::CartLinesDiscountsGenerateRunResult> {
|
||||
let none = || Ok(schema::CartLinesDiscountsGenerateRunResult { operations: vec![] });
|
||||
|
||||
let discount = input.discount();
|
||||
if !discount.discount_classes().contains(&schema::DiscountClass::Product) {
|
||||
return none();
|
||||
}
|
||||
let cfg = match discount.metafield() {
|
||||
Some(m) => m.json_value(),
|
||||
None => return none(),
|
||||
};
|
||||
if cfg.v != 1 || cfg.offers.is_empty() {
|
||||
return none();
|
||||
}
|
||||
|
||||
let ctx = Context {
|
||||
rate: input.presentment_currency_rate().as_f64(),
|
||||
country: input.localization().country().iso_code().as_str().into(),
|
||||
past_guard: *input.shop().local_time().past_guard(),
|
||||
};
|
||||
let (offers, offer_index) = offers_for(cfg, &ctx);
|
||||
if offers.is_empty() {
|
||||
return none();
|
||||
}
|
||||
|
||||
// Eligible lines (F11)
|
||||
let ex = cfg.ex.as_ref();
|
||||
let skip_gift_cards = ex.and_then(|e| e.gc).unwrap_or(true);
|
||||
let skip_subscriptions = ex.and_then(|e| e.subs).unwrap_or(true);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut line_ids: Vec<&str> = Vec::new();
|
||||
for line in input.cart().lines() {
|
||||
let variant = match line.merchandise() {
|
||||
Merchandise::ProductVariant(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
let qty = *line.quantity();
|
||||
let price = line.cost().amount_per_quantity().amount().as_f64();
|
||||
if qty < 1 || !(price > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let product = variant.product();
|
||||
if *product.has_any_tag() {
|
||||
continue;
|
||||
}
|
||||
if skip_gift_cards && *product.is_gift_card() {
|
||||
continue;
|
||||
}
|
||||
if skip_subscriptions && line.selling_plan_allocation().is_some() {
|
||||
continue;
|
||||
}
|
||||
lines.push(Line {
|
||||
variant_id: numeric_id(variant.id()),
|
||||
product_id: numeric_id(product.id()),
|
||||
collection_ids: product
|
||||
.in_collections()
|
||||
.iter()
|
||||
.filter(|c| *c.is_member())
|
||||
.map(|c| numeric_id(c.collection_id()))
|
||||
.collect(),
|
||||
unit_price: price,
|
||||
quantity: qty as usize,
|
||||
});
|
||||
line_ids.push(line.id().as_str());
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return none();
|
||||
}
|
||||
|
||||
let apps = apply_offers(&lines, &offers);
|
||||
if apps.is_empty() {
|
||||
return none();
|
||||
}
|
||||
let totals = allocate_by_line(&apps, offers.len(), lines.len(), cfg.ba.as_deref() == Some("get"));
|
||||
|
||||
// Round per offer (largest remainder), then one candidate per cart line. A line can carry units of two
|
||||
// offers (e.g. qty 4 split over two bundles); those amounts are merged so the line gets one fixed amount.
|
||||
let mut order: Vec<usize> = Vec::new(); // lines in first-discounted order
|
||||
let mut amount = vec![0.0f64; lines.len()];
|
||||
let mut names: Vec<Vec<&str>> = vec![Vec::new(); lines.len()];
|
||||
for (oi, per_line) in totals.iter().enumerate() {
|
||||
let items: Vec<(usize, f64)> = per_line.iter().enumerate().filter_map(|(li, a)| a.map(|a| (li, a))).collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rounded = largest_remainder_cents(&items.iter().map(|(_, a)| *a).collect::<Vec<_>>());
|
||||
for (k, (li, _)) in items.iter().enumerate() {
|
||||
if !(rounded[k] > 0.0) {
|
||||
continue;
|
||||
}
|
||||
if names[*li].is_empty() {
|
||||
order.push(*li);
|
||||
}
|
||||
amount[*li] += rounded[k];
|
||||
let name = cfg.offers[offer_index[oi]].n.as_str();
|
||||
names[*li].push(if name.is_empty() { "Bundle offer" } else { name });
|
||||
}
|
||||
}
|
||||
if order.is_empty() {
|
||||
return none();
|
||||
}
|
||||
|
||||
let candidates = order
|
||||
.iter()
|
||||
.map(|&li| {
|
||||
let mut message = names[li].join(" + ");
|
||||
if message.len() > 255 {
|
||||
let mut cut = 255;
|
||||
while !message.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
message.truncate(cut);
|
||||
}
|
||||
schema::ProductDiscountCandidate {
|
||||
targets: vec![schema::ProductDiscountCandidateTarget::CartLine(schema::CartLineTarget {
|
||||
id: line_ids[li].to_string(),
|
||||
quantity: None,
|
||||
})],
|
||||
message: Some(message),
|
||||
value: schema::ProductDiscountCandidateValue::FixedAmount(schema::ProductDiscountCandidateFixedAmount {
|
||||
amount: Decimal((amount[li] * 100.0).round() / 100.0),
|
||||
applies_to_each_item: Some(false),
|
||||
}),
|
||||
associated_discount_code: None,
|
||||
prerequisites: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(schema::CartLinesDiscountsGenerateRunResult {
|
||||
operations: vec![schema::CartOperation::ProductDiscountsAdd(schema::ProductDiscountsAddOperation {
|
||||
selection_strategy: schema::ProductDiscountSelectionStrategy::All,
|
||||
candidates,
|
||||
})],
|
||||
})
|
||||
}
|
||||
142
extensions/bundle-discount/src/config.rs
Normal file
142
extensions/bundle-discount/src/config.rs
Normal file
@ -0,0 +1,142 @@
|
||||
//! The compact $app:configuration JSON (spec/offer-config.schema.json), written by the app.
|
||||
use shopify_function::prelude::*;
|
||||
|
||||
use crate::engine::{DiscountType, FixedLine, Kind, Offer, Scope};
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
pub struct Config {
|
||||
#[shopify_function(default)]
|
||||
pub v: i64,
|
||||
pub ex: Option<Exclusions>,
|
||||
/// bxgy allocation: "all" (default, Odoo parity) or "get" (get units only)
|
||||
pub ba: Option<String>,
|
||||
/// UTC guard time (earliest offer end); see run.rs
|
||||
pub g: Option<String>,
|
||||
#[shopify_function(default)]
|
||||
pub offers: Vec<ConfigOffer>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
pub struct Exclusions {
|
||||
pub subs: Option<bool>,
|
||||
pub gc: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
pub struct ConfigScope {
|
||||
#[shopify_function(default)]
|
||||
pub v: Vec<String>,
|
||||
#[shopify_function(default)]
|
||||
pub p: Vec<String>,
|
||||
#[shopify_function(default)]
|
||||
pub c: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
pub struct ConfigLine {
|
||||
pub v: Option<String>,
|
||||
pub p: Option<String>,
|
||||
#[shopify_function(default)]
|
||||
pub q: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
pub struct ConfigOffer {
|
||||
#[shopify_function(default)]
|
||||
pub id: String,
|
||||
#[shopify_function(default)]
|
||||
pub n: String,
|
||||
#[shopify_function(default)]
|
||||
pub t: String,
|
||||
pub e: Option<String>,
|
||||
pub mu: Option<i64>,
|
||||
pub dt: Option<String>,
|
||||
pub dv: Option<f64>,
|
||||
pub l: Option<Vec<ConfigLine>>,
|
||||
pub sc: Option<ConfigScope>,
|
||||
pub q: Option<i64>,
|
||||
pub bs: Option<ConfigScope>,
|
||||
pub bq: Option<i64>,
|
||||
pub gs: Option<ConfigScope>,
|
||||
pub gq: Option<i64>,
|
||||
pub gp: Option<f64>,
|
||||
/// optional country codes (market targeting)
|
||||
pub cc: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn scope(s: &Option<ConfigScope>) -> Scope {
|
||||
match s {
|
||||
Some(s) => Scope { variant_ids: s.v.clone(), product_ids: s.p.clone(), collection_ids: s.c.clone() },
|
||||
None => Scope::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn count(n: Option<i64>) -> usize {
|
||||
n.unwrap_or(0).max(0) as usize
|
||||
}
|
||||
|
||||
pub struct Context<'a> {
|
||||
/// presentment currency rate (shop currency -> cart currency)
|
||||
pub rate: f64,
|
||||
pub country: Option<&'a str>,
|
||||
pub past_guard: bool,
|
||||
}
|
||||
|
||||
/// Engine offers for this cart, in priority order, plus the index of each one in `cfg.offers`.
|
||||
pub fn offers_for(cfg: &Config, ctx: &Context) -> (Vec<Offer>, Vec<usize>) {
|
||||
let rate = if ctx.rate > 0.0 { ctx.rate } else { 1.0 };
|
||||
let mut out = Vec::new();
|
||||
let mut idx = Vec::new();
|
||||
for (i, o) in cfg.offers.iter().enumerate() {
|
||||
// Safety net for a late scheduler: once the guard time passed, drop offers ending by then.
|
||||
if ctx.past_guard {
|
||||
if let (Some(e), Some(g)) = (&o.e, &cfg.g) {
|
||||
if e.as_str() <= g.as_str() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cc) = &o.cc {
|
||||
if !cc.is_empty() && !ctx.country.map_or(false, |c| cc.iter().any(|x| x == c)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let dt = DiscountType::parse(o.dt.as_deref());
|
||||
// amount / fixed_price are in shop currency; cart prices are in presentment currency
|
||||
let value = match dt {
|
||||
DiscountType::Amount | DiscountType::FixedPrice => o.dv.unwrap_or(0.0) * rate,
|
||||
_ => o.dv.unwrap_or(0.0),
|
||||
};
|
||||
let kind = match o.t.as_str() {
|
||||
"fixed" => Kind::Fixed {
|
||||
lines: o
|
||||
.l
|
||||
.as_ref()
|
||||
.map(|ls| {
|
||||
ls.iter()
|
||||
.map(|l| FixedLine {
|
||||
variant_id: l.v.clone(),
|
||||
product_id: if l.v.is_some() { None } else { l.p.clone() },
|
||||
quantity: l.q.max(0) as usize,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
dt,
|
||||
value,
|
||||
},
|
||||
"mix" => Kind::Mix { qty: count(o.q), scope: scope(&o.sc), dt, value },
|
||||
"bxgy" => Kind::Bxgy {
|
||||
buy_qty: count(o.bq),
|
||||
get_qty: count(o.gq),
|
||||
get_percent: o.gp.unwrap_or(0.0),
|
||||
buy: scope(&o.bs),
|
||||
get: scope(&o.gs),
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
out.push(Offer { max_uses: count(o.mu), kind });
|
||||
idx.push(i);
|
||||
}
|
||||
(out, idx)
|
||||
}
|
||||
339
extensions/bundle-discount/src/engine.rs
Normal file
339
extensions/bundle-discount/src/engine.rs
Normal file
@ -0,0 +1,339 @@
|
||||
//! Rust port of the bundle engine (engine/engine.js, itself a port of the Odoo bundle_engine.py).
|
||||
//! Pure code, no Shopify types: tested against spec/test-vectors.json in tests/vectors.rs.
|
||||
//!
|
||||
//! Behaviour that MUST be preserved (it decides which items are discounted):
|
||||
//! * One unit per item; pool order = cart line order, then unit order.
|
||||
//! * Offers are tried in priority order; every unit an offer uses leaves the pool.
|
||||
//! * An offer repeats while it still matches, up to max_uses (0 = unlimited).
|
||||
//! * Matching units are sorted by price with a STABLE sort (ties keep pool order).
|
||||
//! * fixed: per bundle line, cheapest matching units first; all-or-nothing.
|
||||
//! * mix: cheapest matching units first.
|
||||
//! * bxgy: "buy" = MOST expensive matching units, "get" = cheapest remaining matching units.
|
||||
//! * A use that gives 0 discount puts its units back at the END of the pool and stops that offer.
|
||||
//! * The discount of a use is spread over ALL its units in proportion to price (bxgy included),
|
||||
//! unless the shop chose "get units only".
|
||||
//!
|
||||
//! Performance: matching is decided per cart LINE (all units of a line match alike), and each offer
|
||||
//! sorts its candidate lists once (counting sort on price rank), then consumes them in order
|
||||
//! (same result as re-filtering and re-sorting the pool for every use).
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum DiscountType {
|
||||
Percent,
|
||||
Amount,
|
||||
FixedPrice,
|
||||
None,
|
||||
}
|
||||
|
||||
impl DiscountType {
|
||||
pub fn parse(s: Option<&str>) -> Self {
|
||||
match s {
|
||||
Some("percent") => Self::Percent,
|
||||
Some("amount") => Self::Amount,
|
||||
Some("fixed_price") => Self::FixedPrice,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bundle_discount(regular: f64, dt: DiscountType, value: f64) -> f64 {
|
||||
let regular = regular.max(0.0);
|
||||
let value = value.max(0.0);
|
||||
match dt {
|
||||
DiscountType::Percent => (regular * value.min(100.0)) / 100.0,
|
||||
DiscountType::Amount => value.min(regular),
|
||||
DiscountType::FixedPrice => (regular - value).max(0.0),
|
||||
DiscountType::None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A cart line as the engine sees it (ids borrow from the function input: no copies).
|
||||
pub struct Line<'a> {
|
||||
pub variant_id: &'a str,
|
||||
pub product_id: &'a str,
|
||||
pub collection_ids: Vec<&'a str>,
|
||||
pub unit_price: f64,
|
||||
pub quantity: usize,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Scope {
|
||||
pub variant_ids: Vec<String>,
|
||||
pub product_ids: Vec<String>,
|
||||
pub collection_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn matches(&self, line: &Line<'_>) -> bool {
|
||||
self.variant_ids.iter().any(|v| v == line.variant_id)
|
||||
|| self.product_ids.iter().any(|p| p == line.product_id)
|
||||
|| line.collection_ids.iter().any(|c| self.collection_ids.iter().any(|s| s == c))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FixedLine {
|
||||
pub variant_id: Option<String>,
|
||||
pub product_id: Option<String>,
|
||||
pub quantity: usize,
|
||||
}
|
||||
|
||||
pub enum Kind {
|
||||
Fixed { lines: Vec<FixedLine>, dt: DiscountType, value: f64 },
|
||||
Mix { qty: usize, scope: Scope, dt: DiscountType, value: f64 },
|
||||
Bxgy { buy_qty: usize, get_qty: usize, get_percent: f64, buy: Scope, get: Scope },
|
||||
}
|
||||
|
||||
pub struct Offer {
|
||||
pub max_uses: usize,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Unit {
|
||||
line: usize,
|
||||
price: f64,
|
||||
/// rank of the price among the distinct cart prices (0 = cheapest)
|
||||
rank: u32,
|
||||
}
|
||||
|
||||
pub struct Application {
|
||||
pub offer: usize,
|
||||
pub discount: f64,
|
||||
/// line index of every unit used, in use order
|
||||
pub unit_lines: Vec<usize>,
|
||||
pub unit_prices: Vec<f64>,
|
||||
/// bxgy: how many of the trailing units are "get" units
|
||||
pub get_count: usize,
|
||||
}
|
||||
|
||||
struct Candidates {
|
||||
list: Vec<usize>, // unit indices
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
/// Matching pool units sorted by price, ties in pool order. A stable counting sort over price ranks
|
||||
/// (same result as a stable comparison sort, in linear time: the sort dominated the instruction count).
|
||||
fn candidates(pool: &[usize], units: &[Unit], line_ok: &[bool], n_ranks: usize, cheapest_first: bool) -> Candidates {
|
||||
let bucket = |u: usize| {
|
||||
let r = units[u].rank as usize;
|
||||
if cheapest_first { r } else { n_ranks - 1 - r }
|
||||
};
|
||||
let mut counts = vec![0usize; n_ranks + 1];
|
||||
let mut n = 0;
|
||||
for &u in pool {
|
||||
if line_ok[units[u].line] {
|
||||
counts[bucket(u) + 1] += 1;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for i in 1..counts.len() {
|
||||
counts[i] += counts[i - 1];
|
||||
}
|
||||
let mut list = vec![0usize; n];
|
||||
for &u in pool {
|
||||
if line_ok[units[u].line] {
|
||||
let b = bucket(u);
|
||||
list[counts[b]] = u;
|
||||
counts[b] += 1;
|
||||
}
|
||||
}
|
||||
Candidates { list, pos: 0 }
|
||||
}
|
||||
|
||||
fn take_from(c: &mut Candidates, used: &mut [bool], qty: usize, out: &mut Vec<usize>) -> bool {
|
||||
let start = out.len();
|
||||
let mut i = c.pos;
|
||||
while i < c.list.len() && out.len() - start < qty {
|
||||
let u = c.list[i];
|
||||
if !used[u] {
|
||||
out.push(u);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if out.len() - start < qty {
|
||||
out.truncate(start);
|
||||
return false;
|
||||
}
|
||||
for &u in &out[start..] {
|
||||
used[u] = true;
|
||||
}
|
||||
while c.pos < c.list.len() && used[c.list[c.pos]] {
|
||||
c.pos += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn sum(units: &[Unit], idx: &[usize]) -> f64 {
|
||||
let mut s = 0.0;
|
||||
for &u in idx {
|
||||
s += units[u].price;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Runs all offers in priority order over the cart lines.
|
||||
pub fn apply_offers(lines: &[Line<'_>], offers: &[Offer]) -> Vec<Application> {
|
||||
// Distinct prices, ascending. Prices are > 0, so the f64 bit pattern orders like the number.
|
||||
let mut prices: Vec<u64> = lines.iter().map(|l| l.unit_price.to_bits()).collect();
|
||||
prices.sort_unstable();
|
||||
prices.dedup();
|
||||
let n_ranks = prices.len().max(1);
|
||||
let mut units: Vec<Unit> = Vec::new();
|
||||
for (li, l) in lines.iter().enumerate() {
|
||||
let rank = prices.binary_search(&l.unit_price.to_bits()).unwrap_or(0) as u32;
|
||||
for _ in 0..l.quantity {
|
||||
units.push(Unit { line: li, price: l.unit_price, rank });
|
||||
}
|
||||
}
|
||||
let mut pool: Vec<usize> = (0..units.len()).collect();
|
||||
let mut used = vec![false; units.len()];
|
||||
let mut apps = Vec::new();
|
||||
|
||||
for (oi, offer) in offers.iter().enumerate() {
|
||||
// (qty, candidate list) parts of one use
|
||||
let mut parts: Vec<(usize, Candidates)> = match &offer.kind {
|
||||
Kind::Fixed { lines: fl, .. } => {
|
||||
let fl: Vec<&FixedLine> = fl.iter().filter(|l| l.quantity > 0).collect();
|
||||
if fl.is_empty() {
|
||||
continue;
|
||||
}
|
||||
fl.iter()
|
||||
.map(|bl| {
|
||||
let ok: Vec<bool> = lines
|
||||
.iter()
|
||||
.map(|l| match (&bl.variant_id, &bl.product_id) {
|
||||
(Some(v), _) => v == l.variant_id,
|
||||
(None, Some(p)) => p == l.product_id, // product-level line: any variant
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
(bl.quantity, candidates(&pool, &units, &ok, n_ranks, true))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Kind::Mix { qty, scope, .. } => {
|
||||
if *qty == 0 {
|
||||
continue;
|
||||
}
|
||||
let ok: Vec<bool> = lines.iter().map(|l| scope.matches(l)).collect();
|
||||
vec![(*qty, candidates(&pool, &units, &ok, n_ranks, true))]
|
||||
}
|
||||
Kind::Bxgy { buy_qty, get_qty, buy, get, .. } => {
|
||||
if *buy_qty == 0 || *get_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
let buy_ok: Vec<bool> = lines.iter().map(|l| buy.matches(l)).collect();
|
||||
let get_ok: Vec<bool> = lines.iter().map(|l| get.matches(l)).collect();
|
||||
vec![
|
||||
(*buy_qty, candidates(&pool, &units, &buy_ok, n_ranks, false)),
|
||||
(*get_qty, candidates(&pool, &units, &get_ok, n_ranks, true)),
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
let mut taken_by_offer: Vec<usize> = Vec::new();
|
||||
let mut returned: Vec<usize> = Vec::new();
|
||||
let mut uses = 0usize;
|
||||
while offer.max_uses == 0 || uses < offer.max_uses {
|
||||
let mut use_units: Vec<usize> = Vec::new();
|
||||
let mut part_starts: Vec<usize> = Vec::new();
|
||||
let mut failed = false;
|
||||
for (qty, cand) in parts.iter_mut() {
|
||||
part_starts.push(use_units.len());
|
||||
if !take_from(cand, &mut used, *qty, &mut use_units) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
for &u in &use_units {
|
||||
used[u] = false; // all or nothing: restore, stop this offer
|
||||
}
|
||||
break;
|
||||
}
|
||||
let (discount, get_count) = match &offer.kind {
|
||||
Kind::Bxgy { get_percent, .. } => {
|
||||
let get_part = &use_units[part_starts[1]..];
|
||||
let pct = get_percent.max(0.0).min(100.0);
|
||||
((sum(&units, get_part) * pct) / 100.0, get_part.len())
|
||||
}
|
||||
Kind::Fixed { dt, value, .. } | Kind::Mix { dt, value, .. } => {
|
||||
(bundle_discount(sum(&units, &use_units), *dt, *value), 0)
|
||||
}
|
||||
};
|
||||
if discount <= 0.0 {
|
||||
for &u in &use_units {
|
||||
used[u] = false;
|
||||
}
|
||||
returned = use_units; // back at the END of the pool
|
||||
break;
|
||||
}
|
||||
taken_by_offer.extend_from_slice(&use_units);
|
||||
apps.push(Application {
|
||||
offer: oi,
|
||||
discount,
|
||||
unit_lines: use_units.iter().map(|&u| units[u].line).collect(),
|
||||
unit_prices: use_units.iter().map(|&u| units[u].price).collect(),
|
||||
get_count,
|
||||
});
|
||||
uses += 1;
|
||||
}
|
||||
|
||||
if !taken_by_offer.is_empty() || !returned.is_empty() {
|
||||
let mut back = vec![false; units.len()];
|
||||
for &u in &returned {
|
||||
back[u] = true;
|
||||
}
|
||||
pool.retain(|&u| !used[u] && !back[u]);
|
||||
pool.extend_from_slice(&returned);
|
||||
}
|
||||
// units taken by this offer stay marked used (they left the pool for good)
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// Discount per (offer, line), spread by price like Odoo's split_by(). Returns totals[offer][line].
|
||||
pub fn allocate_by_line(apps: &[Application], n_offers: usize, n_lines: usize, get_units_only: bool) -> Vec<Vec<Option<f64>>> {
|
||||
let mut totals = vec![vec![None; n_lines]; n_offers];
|
||||
for app in apps {
|
||||
let from = if get_units_only && app.get_count > 0 { app.unit_lines.len() - app.get_count } else { 0 };
|
||||
let lines = &app.unit_lines[from..];
|
||||
let prices = &app.unit_prices[from..];
|
||||
let mut base = 0.0;
|
||||
for p in prices {
|
||||
base += p;
|
||||
}
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
let share = if base != 0.0 { app.discount * (prices[i] / base) } else { app.discount / lines.len() as f64 };
|
||||
let slot = &mut totals[app.offer][line];
|
||||
*slot = Some(slot.unwrap_or(0.0) + share);
|
||||
}
|
||||
}
|
||||
totals
|
||||
}
|
||||
|
||||
/// Round amounts to cents so they add up to round(total): largest remainder, ties by input order.
|
||||
pub fn largest_remainder_cents(amounts: &[f64]) -> Vec<f64> {
|
||||
let exact: Vec<f64> = amounts.iter().map(|a| a * 100.0).collect();
|
||||
let mut total = 0.0;
|
||||
for a in amounts {
|
||||
total += a;
|
||||
}
|
||||
let target = (total * 100.0 + 1e-9).round() as i64;
|
||||
let mut floors: Vec<i64> = exact.iter().map(|c| (c + 1e-9).floor() as i64).collect();
|
||||
let mut remainder = target - floors.iter().sum::<i64>();
|
||||
let mut order: Vec<usize> = (0..exact.len()).collect();
|
||||
order.sort_by(|&i, &j| {
|
||||
let ri = exact[i] - floors[i] as f64;
|
||||
let rj = exact[j] - floors[j] as f64;
|
||||
rj.partial_cmp(&ri).unwrap_or(std::cmp::Ordering::Equal).then(i.cmp(&j))
|
||||
});
|
||||
for i in order {
|
||||
if remainder <= 0 {
|
||||
break;
|
||||
}
|
||||
floors[i] += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
floors.iter().map(|&f| f as f64 / 100.0).collect()
|
||||
}
|
||||
22
extensions/bundle-discount/src/main.rs
Normal file
22
extensions/bundle-discount/src/main.rs
Normal file
@ -0,0 +1,22 @@
|
||||
use shopify_function::prelude::*;
|
||||
use std::process;
|
||||
|
||||
pub mod cart_lines_discounts_generate_run;
|
||||
pub mod config;
|
||||
pub mod engine;
|
||||
|
||||
#[typegen("schema.graphql")]
|
||||
pub mod schema {
|
||||
#[query("src/cart_lines_discounts_generate_run.graphql", custom_scalar_overrides = {
|
||||
"Input.discount.metafield.jsonValue" => super::config::Config,
|
||||
})]
|
||||
pub mod cart_lines_discounts_generate_run {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
log!("Please invoke a named export.");
|
||||
process::abort();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
203
extensions/bundle-discount/src/tests.rs
Normal file
203
extensions/bundle-discount/src/tests.rs
Normal file
@ -0,0 +1,203 @@
|
||||
//! cargo test: engine parity with spec/test-vectors.json (generated by the ORIGINAL Odoo engine)
|
||||
//! and end-to-end function behaviour on JSON inputs.
|
||||
use crate::engine::*;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const VECTORS: &str = include_str!("../../../spec/test-vectors.json");
|
||||
|
||||
fn strs(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().map(|x| x.as_str().unwrap().to_string()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn scope(v: &Value) -> Scope {
|
||||
Scope { variant_ids: strs(&v["variant_ids"]), product_ids: strs(&v["product_ids"]), collection_ids: strs(&v["collection_ids"]) }
|
||||
}
|
||||
|
||||
fn usize_of(v: &Value) -> usize {
|
||||
v.as_u64().unwrap_or(0) as usize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_vectors() {
|
||||
let doc: Value = serde_json::from_str(VECTORS).unwrap();
|
||||
let vectors = doc["vectors"].as_array().unwrap();
|
||||
assert_eq!(vectors.len(), 19);
|
||||
for v in vectors {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let cart_lines = v["cart"]["lines"].as_array().unwrap();
|
||||
let col_store: Vec<Vec<String>> = cart_lines.iter().map(|l| strs(&l["collection_ids"])).collect();
|
||||
let lines: Vec<Line> = cart_lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| Line {
|
||||
variant_id: l["variant_id"].as_str().unwrap_or(""),
|
||||
product_id: l["product_id"].as_str().unwrap_or(""),
|
||||
collection_ids: col_store[i].iter().map(|s| s.as_str()).collect(),
|
||||
unit_price: l["unit_price"].as_f64().unwrap(),
|
||||
quantity: usize_of(&l["quantity"]),
|
||||
})
|
||||
.collect();
|
||||
let offers_json = v["offers"].as_array().unwrap();
|
||||
let offers: Vec<Offer> = offers_json
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let dt = DiscountType::parse(o["discount_type"].as_str());
|
||||
let value = o["value"].as_f64().unwrap_or(0.0);
|
||||
let kind = match o["type"].as_str().unwrap() {
|
||||
"fixed" => Kind::Fixed {
|
||||
lines: o["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| FixedLine {
|
||||
variant_id: l["variant_id"].as_str().map(String::from),
|
||||
product_id: if l["variant_id"].is_string() { None } else { l["product_id"].as_str().map(String::from) },
|
||||
quantity: usize_of(&l["quantity"]),
|
||||
})
|
||||
.collect(),
|
||||
dt,
|
||||
value,
|
||||
},
|
||||
"mix" => Kind::Mix { qty: usize_of(&o["qty"]), scope: scope(&o["scope"]), dt, value },
|
||||
_ => Kind::Bxgy {
|
||||
buy_qty: usize_of(&o["buy_qty"]),
|
||||
get_qty: usize_of(&o["get_qty"]),
|
||||
get_percent: o["get_percent"].as_f64().unwrap_or(0.0),
|
||||
buy: scope(&o["buy_scope"]),
|
||||
get: scope(&o["get_scope"]),
|
||||
},
|
||||
};
|
||||
Offer { max_uses: usize_of(&o["max_uses"]), kind }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let apps = apply_offers(&lines, &offers);
|
||||
let exp_apps = v["expected"]["applications"].as_array().unwrap();
|
||||
assert_eq!(apps.len(), exp_apps.len(), "{name}: number of applications");
|
||||
for (a, e) in apps.iter().zip(exp_apps) {
|
||||
assert_eq!(offers_json[a.offer]["id"], e["offer_id"], "{name}");
|
||||
assert!((a.discount - e["discount"].as_f64().unwrap()).abs() < 1e-6, "{name}: discount");
|
||||
let used: Vec<&str> = a.unit_lines.iter().map(|&li| cart_lines[li]["id"].as_str().unwrap()).collect();
|
||||
let exp_used: Vec<&str> = e["line_ids_used"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect();
|
||||
assert_eq!(used, exp_used, "{name}: units used");
|
||||
}
|
||||
|
||||
let totals = allocate_by_line(&apps, offers.len(), lines.len(), false);
|
||||
let mut got = Vec::new();
|
||||
for (oi, per_line) in totals.iter().enumerate() {
|
||||
let items: Vec<(usize, f64)> = per_line.iter().enumerate().filter_map(|(li, a)| a.map(|a| (li, a))).collect();
|
||||
let rounded = largest_remainder_cents(&items.iter().map(|x| x.1).collect::<Vec<_>>());
|
||||
for (k, (li, exact)) in items.iter().enumerate() {
|
||||
got.push((offers_json[oi]["id"].as_str().unwrap(), cart_lines[*li]["id"].as_str().unwrap(), *exact, rounded[k]));
|
||||
}
|
||||
}
|
||||
let exp_alloc = v["expected"]["allocations"].as_array().unwrap();
|
||||
assert_eq!(got.len(), exp_alloc.len(), "{name}: number of allocations");
|
||||
for (g, e) in got.iter().zip(exp_alloc) {
|
||||
assert_eq!(g.0, e["offer_id"].as_str().unwrap(), "{name}");
|
||||
assert_eq!(g.1, e["line_id"].as_str().unwrap(), "{name}");
|
||||
assert!((g.2 - e["amount_exact"].as_f64().unwrap()).abs() < 1e-6, "{name}: amount_exact");
|
||||
assert!((g.3 - e["amount"].as_f64().unwrap()).abs() < 1e-9, "{name}: amount");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- end-to-end: JSON input -> function output ----
|
||||
|
||||
fn line(id: u32, v: u32, p: u32, price: f64, qty: u32, cols: &[u32]) -> Value {
|
||||
json!({
|
||||
"id": format!("gid://shopify/CartLine/{id}"),
|
||||
"quantity": qty,
|
||||
"cost": { "amountPerQuantity": { "amount": price.to_string() } },
|
||||
"sellingPlanAllocation": null,
|
||||
"merchandise": {
|
||||
"__typename": "ProductVariant",
|
||||
"id": format!("gid://shopify/ProductVariant/{v}"),
|
||||
"product": {
|
||||
"id": format!("gid://shopify/Product/{p}"),
|
||||
"isGiftCard": false,
|
||||
"hasAnyTag": false,
|
||||
"inCollections": cols.iter().map(|c| json!({ "collectionId": format!("gid://shopify/Collection/{c}"), "isMember": true })).collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(lines: Vec<Value>, config: Value, rate: &str, past_guard: bool) -> Vec<(String, String, f64)> {
|
||||
let input = json!({
|
||||
"cart": { "lines": lines },
|
||||
"localization": { "country": { "isoCode": "CA" } },
|
||||
"presentmentCurrencyRate": rate,
|
||||
"shop": { "localTime": { "pastGuard": past_guard } },
|
||||
"discount": { "discountClasses": ["PRODUCT"], "metafield": { "jsonValue": config } }
|
||||
});
|
||||
let out = shopify_function::run_function_with_input(
|
||||
crate::cart_lines_discounts_generate_run::cart_lines_discounts_generate_run,
|
||||
&input.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut res = Vec::new();
|
||||
for op in out.operations {
|
||||
if let crate::schema::CartOperation::ProductDiscountsAdd(add) = op {
|
||||
assert_eq!(add.selection_strategy, crate::schema::ProductDiscountSelectionStrategy::All);
|
||||
for c in add.candidates {
|
||||
let crate::schema::ProductDiscountCandidateTarget::CartLine(t) = &c.targets[0];
|
||||
let crate::schema::ProductDiscountCandidateValue::FixedAmount(f) = &c.value else { panic!() };
|
||||
res.push((t.id.rsplit('/').next().unwrap().to_string(), c.message.clone().unwrap(), f.amount.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_fixed_mix_bxgy_and_priority() {
|
||||
let cfg = json!({ "v": 1, "offers": [
|
||||
{ "id": "o1", "n": "Puja Kit", "t": "fixed", "dt": "percent", "dv": 20, "l": [{ "v": "11", "q": 1 }, { "p": "2", "q": 2 }] },
|
||||
{ "id": "o2", "n": "Any 3 Sweets", "t": "mix", "dt": "fixed_price", "dv": 20, "q": 3, "sc": { "c": ["50"] } },
|
||||
{ "id": "o3", "n": "B2G1", "t": "bxgy", "bq": 2, "gq": 1, "gp": 100, "bs": { "c": ["60"] }, "gs": { "c": ["60"] } }
|
||||
]});
|
||||
let r = run(vec![
|
||||
line(1, 11, 1, 10.0, 1, &[]),
|
||||
line(2, 21, 2, 5.0, 2, &[50]), // product 2 is used by the fixed bundle first (priority)
|
||||
line(3, 31, 3, 8.0, 3, &[50]), // 3 sweets: 24 -> 20 => 4 off
|
||||
line(4, 41, 4, 30.0, 1, &[60]),
|
||||
line(5, 51, 5, 20.0, 1, &[60]),
|
||||
line(6, 61, 6, 10.0, 1, &[60]),
|
||||
], cfg, "1.0", false);
|
||||
assert_eq!(r, vec![
|
||||
("1".into(), "Puja Kit".into(), 2.0),
|
||||
("2".into(), "Puja Kit".into(), 2.0),
|
||||
("3".into(), "Any 3 Sweets".into(), 4.0),
|
||||
("4".into(), "B2G1".into(), 5.0),
|
||||
("5".into(), "B2G1".into(), 3.33),
|
||||
("6".into(), "B2G1".into(), 1.67),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_currency_guard_and_merge() {
|
||||
// bundle price 20 shop currency * 1.5 = 30 presentment; 3 x 15 = 45 -> 15 off
|
||||
let cfg = json!({ "v": 1, "offers": [{ "id": "m", "n": "M", "t": "mix", "dt": "fixed_price", "dv": 20, "q": 3, "sc": { "p": ["1"] } }] });
|
||||
assert_eq!(run(vec![line(1, 1, 1, 15.0, 3, &[])], cfg, "1.5", false), vec![("1".into(), "M".into(), 15.0)]);
|
||||
|
||||
let g = "2026-11-09T04:59:59Z";
|
||||
let cfg = json!({ "v": 1, "g": g, "offers": [{ "id": "m", "n": "M", "t": "mix", "dt": "percent", "dv": 10, "q": 1, "e": g, "sc": { "p": ["1"] } }] });
|
||||
assert_eq!(run(vec![line(1, 1, 1, 10.0, 1, &[])], cfg.clone(), "1.0", false).len(), 1);
|
||||
assert!(run(vec![line(1, 1, 1, 10.0, 1, &[])], cfg, "1.0", true).is_empty(), "ended offer must not apply");
|
||||
|
||||
let cfg = json!({ "v": 1, "offers": [
|
||||
{ "id": "a", "n": "A", "t": "mix", "dt": "amount", "dv": 1, "q": 1, "mu": 1, "sc": { "p": ["1"] } },
|
||||
{ "id": "b", "n": "B", "t": "mix", "dt": "amount", "dv": 2, "q": 1, "mu": 1, "sc": { "p": ["1"] } }
|
||||
]});
|
||||
assert_eq!(run(vec![line(1, 1, 1, 10.0, 2, &[])], cfg, "1.0", false), vec![("1".into(), "A + B".into(), 3.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_get_units_only_setting() {
|
||||
let cfg = json!({ "v": 1, "ba": "get", "offers": [
|
||||
{ "id": "o3", "n": "B2G1", "t": "bxgy", "bq": 2, "gq": 1, "gp": 100, "bs": { "c": ["60"] }, "gs": { "c": ["60"] } }
|
||||
]});
|
||||
let r = run(vec![line(4, 41, 4, 30.0, 1, &[60]), line(5, 51, 5, 20.0, 1, &[60]), line(6, 61, 6, 10.0, 1, &[60])], cfg, "1.0", false);
|
||||
assert_eq!(r, vec![("6".into(), "B2G1".into(), 10.0)]);
|
||||
}
|
||||
162
extensions/bundle-offers-theme/assets/bundle-offers.css
Normal file
162
extensions/bundle-offers-theme/assets/bundle-offers.css
Normal file
@ -0,0 +1,162 @@
|
||||
/* Bundle Offers storefront styles.
|
||||
Inherits the theme: fonts, text colour and background come from the page. Lines are a mix of the text
|
||||
colour, so they work on light and dark themes alike. The merchant can set an accent and a radius (Settings).
|
||||
The one decorative device is the coupon stub on the badge: festival offers as tear-off coupons. */
|
||||
|
||||
:root {
|
||||
--bo-radius: 8px;
|
||||
}
|
||||
|
||||
.bo-section,
|
||||
.bo-box,
|
||||
.bo-hints,
|
||||
.bo-page {
|
||||
--bo-ink: currentColor;
|
||||
--bo-accent-fallback: rgb(var(--color-button, 0 0 0));
|
||||
--bo-accent-color: var(--bo-accent, var(--bo-accent-fallback));
|
||||
--bo-line: color-mix(in srgb, currentColor 14%, transparent);
|
||||
--bo-soft: color-mix(in srgb, currentColor 5%, transparent);
|
||||
--bo-muted: color-mix(in srgb, currentColor 68%, transparent);
|
||||
--bo-on-accent: rgb(var(--color-button-text, 255 255 255));
|
||||
}
|
||||
|
||||
.bo-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
||||
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
.bo-section { margin: 2.5rem auto; max-width: var(--page-width, 120rem); padding: 0 1.5rem; }
|
||||
.bo-section__title { margin: 0; line-height: 1.2; }
|
||||
.bo-section__sub { margin: .25rem 0 0; color: var(--bo-muted); }
|
||||
.bo-link { color: inherit; text-underline-offset: .2em; white-space: nowrap; }
|
||||
|
||||
/* ---------- grid & strip ---------- */
|
||||
.bo-grid { display: grid; gap: 1.5rem; grid-template-columns: 1fr; margin-top: 1.25rem; }
|
||||
@media (min-width: 600px) { .bo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (min-width: 990px) { .bo-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
|
||||
.bo-strip__head { display: flex; align-items: end; justify-content: space-between; gap: 1rem; }
|
||||
.bo-strip__track {
|
||||
list-style: none; margin: 1.25rem 0 0; padding: 0 0 .5rem;
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: 80%; gap: 1rem;
|
||||
overflow-x: auto; scroll-snap-type: x mandatory; overscroll-behavior-x: contain;
|
||||
}
|
||||
@media (min-width: 600px) { .bo-strip__track { grid-auto-columns: calc((100% - 1rem) / 2.15); } }
|
||||
@media (min-width: 990px) { .bo-strip__track { grid-auto-columns: calc((100% - 3rem) / 4); } }
|
||||
.bo-strip__item { scroll-snap-align: start; display: flex; }
|
||||
|
||||
/* ---------- card ---------- */
|
||||
.bo-card {
|
||||
position: relative; display: flex; flex-direction: column; width: 100%;
|
||||
border: 1px solid var(--bo-line); border-radius: var(--bo-radius); overflow: hidden;
|
||||
}
|
||||
.bo-card__media { display: block; aspect-ratio: 4 / 3; background: var(--bo-soft); }
|
||||
.bo-card__media > img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.bo-card__thumbs { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; height: 100%; }
|
||||
.bo-card__thumbs img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.bo-card__thumbs--1 { grid-template-columns: 1fr; }
|
||||
.bo-card__thumbs--3 img:first-child { grid-row: span 2; }
|
||||
.bo-card__body { display: flex; flex-direction: column; gap: .4rem; padding: 1rem 1.1rem 1.15rem; flex: 1; }
|
||||
.bo-card__title { margin: 0; font-size: 1.15em; line-height: 1.3; }
|
||||
.bo-card__title a { color: inherit; text-decoration: none; }
|
||||
.bo-card__title a::after { content: ""; position: absolute; inset: 0; } /* whole card is clickable */
|
||||
.bo-card__title a:focus-visible { outline: none; }
|
||||
.bo-card:has(.bo-card__title a:focus-visible) { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.bo-card__deal { margin: 0; font-weight: 600; }
|
||||
.bo-card__prices { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .6rem; }
|
||||
.bo-card__now { font-size: 1.25em; font-weight: 700; }
|
||||
.bo-card__was { color: var(--bo-muted); }
|
||||
.bo-card__count, .bo-card__ends { color: var(--bo-muted); font-size: .9em; }
|
||||
.bo-card__ends { margin: 0; }
|
||||
.bo-card .bo-button { margin-top: auto; position: relative; z-index: 1; }
|
||||
|
||||
/* ---------- the coupon stub (signature) ----------
|
||||
A tear-off ticket with notches cut on both short sides and a dashed perforation line. */
|
||||
.bo-stub {
|
||||
position: absolute; top: .75rem; left: 0; margin: 0; z-index: 1;
|
||||
--notch: 6px;
|
||||
padding: .3rem .85rem .3rem .7rem;
|
||||
background: var(--bo-accent-color); color: var(--bo-on-accent);
|
||||
font-size: .8rem; font-weight: 700; line-height: 1.3;
|
||||
-webkit-mask: radial-gradient(circle var(--notch) at 100% 50%, transparent 98%, #000) right / 100% 100% no-repeat;
|
||||
mask: radial-gradient(circle var(--notch) at 100% 50%, transparent 98%, #000) right / 100% 100% no-repeat;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.bo-stub span { display: block; padding-right: .55rem; border-right: 1.5px dashed color-mix(in srgb, currentColor 60%, transparent); }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.bo-button {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
|
||||
min-height: 44px; padding: .65rem 1.25rem; border: 0; border-radius: var(--bo-radius);
|
||||
background: var(--bo-accent-color); color: var(--bo-on-accent);
|
||||
font: inherit; font-weight: 600; text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.bo-button:hover { filter: brightness(1.08); }
|
||||
.bo-button:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
||||
.bo-button[disabled] { opacity: .45; cursor: not-allowed; filter: none; }
|
||||
|
||||
.bo-empty { padding: 2rem 0; display: grid; gap: 1rem; justify-items: start; }
|
||||
.bo-empty p { margin: 0; }
|
||||
.bo-powered { margin-top: 1.5rem; font-size: .8rem; color: var(--bo-muted); }
|
||||
.bo-powered a { color: inherit; }
|
||||
|
||||
/* ---------- product page box ---------- */
|
||||
.bo-box { margin: 1.25rem 0; border: 1px dashed var(--bo-line); border-radius: var(--bo-radius); padding: .9rem 1rem; }
|
||||
.bo-box__title { margin: 0 0 .4rem; font-size: 1em; }
|
||||
.bo-box ul { list-style: none; margin: 0; padding: 0; }
|
||||
.bo-box li + li { border-top: 1px solid var(--bo-line); }
|
||||
.bo-box__row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .25rem 1rem; min-height: 44px; align-items: center; padding: .4rem 0; color: inherit; text-decoration: none; }
|
||||
.bo-box__row:hover .bo-box__name { text-decoration: underline; }
|
||||
.bo-box__name { font-weight: 600; }
|
||||
.bo-box__deal { color: var(--bo-muted); }
|
||||
|
||||
/* ---------- cart hints ---------- */
|
||||
.bo-hints ul { list-style: none; margin: 1rem 0; padding: 0; display: grid; gap: .5rem; }
|
||||
.bo-hint {
|
||||
display: block; padding: .75rem 1rem; border-radius: var(--bo-radius); min-height: 44px;
|
||||
background: var(--bo-soft); border-left: 3px solid var(--bo-accent-color); color: inherit; text-decoration: none;
|
||||
}
|
||||
.bo-hint:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------- bundle page (app proxy) ---------- */
|
||||
.bo-page { max-width: var(--page-width, 120rem); margin: 2rem auto 4rem; padding: 0 1.5rem; display: grid; gap: 2rem; }
|
||||
@media (min-width: 990px) { .bo-page { grid-template-columns: 5fr 6fr; gap: 3.5rem; align-items: start; } }
|
||||
.bo-page__hero { position: relative; border-radius: var(--bo-radius); overflow: hidden; background: var(--bo-soft); aspect-ratio: 4 / 3; }
|
||||
.bo-page__hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.bo-page__hero .bo-card__thumbs { height: 100%; }
|
||||
.bo-page__title { margin: 1rem 0 .25rem; line-height: 1.15; }
|
||||
.bo-page__deal { margin: 0; font-size: 1.35em; font-weight: 700; }
|
||||
.bo-page__desc { margin: 1rem 0; max-width: 60ch; }
|
||||
.bo-page__ends { margin: .5rem 0 0; color: var(--bo-muted); }
|
||||
.bo-page__note { margin: .5rem 0 0; font-size: .9em; color: var(--bo-muted); }
|
||||
.bo-page__h { margin: 0 0 .75rem; font-size: 1.15em; }
|
||||
.bo-page__group + .bo-page__group { margin-top: 1.75rem; }
|
||||
|
||||
.bo-choice { display: grid; grid-template-columns: 64px 1fr auto; gap: .9rem; align-items: center; padding: .7rem 0; border-bottom: 1px solid var(--bo-line); }
|
||||
.bo-choice img, .bo-choice__ph { width: 64px; height: 64px; object-fit: cover; border-radius: calc(var(--bo-radius) / 1.5); background: var(--bo-soft); display: block; }
|
||||
.bo-choice__name { color: inherit; font-weight: 600; text-decoration: none; overflow-wrap: anywhere; }
|
||||
.bo-choice__name:hover { text-decoration: underline; }
|
||||
.bo-choice__price { display: block; color: var(--bo-muted); font-size: .9em; }
|
||||
.bo-choice__qty { font-weight: 600; white-space: nowrap; }
|
||||
.bo-choice--off { opacity: .5; }
|
||||
|
||||
.bo-qty { display: inline-flex; align-items: center; border: 1px solid var(--bo-line); border-radius: 999px; overflow: hidden; }
|
||||
.bo-qty button { width: 44px; height: 44px; border: 0; background: transparent; color: inherit; font-size: 1.2rem; cursor: pointer; }
|
||||
.bo-qty button:focus-visible { outline: 2px solid currentColor; outline-offset: -4px; }
|
||||
.bo-qty input {
|
||||
width: 3rem; height: 44px; border: 0; background: transparent; color: inherit; text-align: center;
|
||||
font: inherit; font-size: 16px; font-weight: 600; -moz-appearance: textfield; appearance: textfield;
|
||||
}
|
||||
.bo-qty input::-webkit-inner-spin-button, .bo-qty input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
|
||||
.bo-totals { margin: 1.25rem 0; padding: 1rem 1.1rem; border-radius: var(--bo-radius); background: var(--bo-soft); display: grid; grid-template-columns: 1fr auto; gap: .35rem 1rem; }
|
||||
.bo-totals dt, .bo-totals dd { margin: 0; }
|
||||
.bo-totals dd { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.bo-totals__now { font-size: 1.3em; font-weight: 700; }
|
||||
.bo-totals__save { font-weight: 700; }
|
||||
.bo-counter { margin: 1rem 0 .5rem; font-weight: 600; }
|
||||
.bo-error { margin: .75rem 0 0; color: rgb(var(--color-error, 177 39 4)); font-weight: 600; }
|
||||
.bo-submit { width: 100%; max-width: 26rem; margin-top: .5rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .bo-strip__track { scroll-behavior: auto; } }
|
||||
159
extensions/bundle-offers-theme/assets/bundle-offers.js
Normal file
159
extensions/bundle-offers-theme/assets/bundle-offers.js
Normal file
@ -0,0 +1,159 @@
|
||||
/* Bundle Offers storefront script (app embed). Vanilla JS, no dependencies.
|
||||
* - converts shop-currency deal amounts when the customer shops in another currency
|
||||
* - bundle pages (/apps/bundles/...): quantity steppers, "N of M chosen", add all items in one request
|
||||
* - refreshes cart hints after AJAX cart changes */
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.BundleOffers) return;
|
||||
|
||||
var cfgEl = document.getElementById('bundle-offers-data');
|
||||
var cfg = {};
|
||||
try { cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {}; } catch (e) { cfg = {}; }
|
||||
var strings = cfg.strings || {};
|
||||
var cur = (window.Shopify && Shopify.currency) || {};
|
||||
var rate = Number(cur.rate) || 1;
|
||||
var active = cur.active || cfg.currency;
|
||||
|
||||
function fmt(amount) {
|
||||
try {
|
||||
return new Intl.NumberFormat(document.documentElement.lang || undefined, { style: 'currency', currency: active }).format(amount);
|
||||
} catch (e) { return amount.toFixed(2) + ' ' + active; }
|
||||
}
|
||||
|
||||
/* ---- currency: $-values are stored in shop currency ---- */
|
||||
function convertMoney(root) {
|
||||
if (rate === 1 && active === cfg.currency) return;
|
||||
root.querySelectorAll('[data-bo-money]').forEach(function (el) {
|
||||
el.textContent = fmt(Number(el.getAttribute('data-bo-money')) * rate);
|
||||
});
|
||||
root.querySelectorAll('[data-bo-fixed]').forEach(function (el) {
|
||||
var regular = Number(el.getAttribute('data-regular')) / 100; // presentment already
|
||||
var dt = el.getAttribute('data-dt');
|
||||
var dv = Number(el.getAttribute('data-dv'));
|
||||
var saving = dt === 'percent' ? regular * Math.min(dv, 100) / 100
|
||||
: dt === 'amount' ? Math.min(dv * rate, regular) : Math.max(regular - dv * rate, 0);
|
||||
var b = el.querySelector('[data-bo-bundle]');
|
||||
if (b) b.textContent = fmt(regular - saving);
|
||||
el.querySelectorAll('[data-bo-save]').forEach(function (s) { s.textContent = fmt(saving); });
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- cart helpers ---- */
|
||||
var root = (window.Shopify && Shopify.routes && Shopify.routes.root) || '/';
|
||||
|
||||
function refreshHints() {
|
||||
var blocks = document.querySelectorAll('[data-bo-hints]');
|
||||
if (!blocks.length) return;
|
||||
fetch(window.location.pathname + window.location.search, { credentials: 'same-origin', headers: { 'X-Requested-With': 'bundle-offers' } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
blocks.forEach(function (el) {
|
||||
var fresh = doc.querySelector('[data-bo-hints="' + el.getAttribute('data-bo-hints') + '"]');
|
||||
if (fresh) { el.innerHTML = fresh.innerHTML; convertMoney(el); }
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// Themes update the cart with fetch; refresh hints after any cart mutation.
|
||||
var origFetch = window.fetch;
|
||||
if (origFetch) {
|
||||
window.fetch = function (input) {
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
var p = origFetch.apply(this, arguments);
|
||||
if (/\/cart\/(add|change|update|clear)/.test(url)) {
|
||||
p.then(function () { setTimeout(refreshHints, 150); }).catch(function () {});
|
||||
}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
document.addEventListener('cart:updated', refreshHints);
|
||||
document.addEventListener('cart:refresh', refreshHints);
|
||||
|
||||
function openDrawerOrCart() {
|
||||
var drawer = document.querySelector('cart-drawer');
|
||||
if (cfg.after === 'drawer' && drawer && typeof drawer.renderContents === 'function') {
|
||||
// Dawn-family themes: re-render the drawer sections, then open it
|
||||
var ids = typeof drawer.getSectionsToRender === 'function'
|
||||
? drawer.getSectionsToRender().map(function (s) { return s.id; }) : ['cart-drawer', 'cart-icon-bubble'];
|
||||
fetch(root + '?sections=' + ids.join(','))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (sections) { drawer.renderContents({ sections: sections }); })
|
||||
.catch(function () { window.location.href = root + 'cart'; });
|
||||
return;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
|
||||
window.location.href = root + 'cart';
|
||||
}
|
||||
|
||||
/* ---- bundle page form (rendered by the app proxy) ---- */
|
||||
function setupBundleForm(form) {
|
||||
var need = Number(form.getAttribute('data-need')) || 0;
|
||||
var type = form.getAttribute('data-type');
|
||||
var counter = form.querySelector('[data-bo-counter]');
|
||||
var submit = form.querySelector('[type="submit"]');
|
||||
var error = form.querySelector('[data-bo-error]');
|
||||
var inputs = form.querySelectorAll('input[data-variant]');
|
||||
|
||||
function total() {
|
||||
var t = 0;
|
||||
inputs.forEach(function (i) { t += Math.max(0, Math.min(99, parseInt(i.value, 10) || 0)); });
|
||||
return t;
|
||||
}
|
||||
function update() {
|
||||
var t = total();
|
||||
if (counter) counter.textContent = counter.getAttribute('data-template').replace('{k}', t).replace('{n}', need);
|
||||
if (submit && type !== 'fixed') submit.disabled = type === 'mix' ? t < need : t < 1;
|
||||
}
|
||||
form.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-step]');
|
||||
if (!btn) return;
|
||||
var input = btn.parentNode.querySelector('input');
|
||||
var v = (parseInt(input.value, 10) || 0) + Number(btn.getAttribute('data-step'));
|
||||
input.value = Math.max(0, Math.min(99, v));
|
||||
update();
|
||||
});
|
||||
form.addEventListener('input', update);
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var items = [];
|
||||
inputs.forEach(function (i) {
|
||||
var q = Math.max(0, Math.min(99, parseInt(i.value, 10) || 0));
|
||||
if (q > 0) items.push({ id: Number(i.getAttribute('data-variant')), quantity: q });
|
||||
});
|
||||
if (type === 'mix' && total() < need) { showError(form.getAttribute('data-need-more')); return; }
|
||||
if (!items.length) { showError(form.getAttribute('data-nothing')); return; }
|
||||
var label = submit.textContent;
|
||||
submit.disabled = true;
|
||||
submit.textContent = strings.adding || '…';
|
||||
origFetch(root + 'cart/add.js', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ items: items }),
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
|
||||
.then(function () { submit.textContent = strings.added || label; openDrawerOrCart(); })
|
||||
.catch(function () {
|
||||
submit.disabled = false;
|
||||
submit.textContent = label;
|
||||
showError(strings.error);
|
||||
});
|
||||
});
|
||||
function showError(msg) {
|
||||
if (!error) return;
|
||||
error.textContent = msg || '';
|
||||
error.hidden = !msg;
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
function init() {
|
||||
convertMoney(document);
|
||||
document.querySelectorAll('form[data-bo-bundle-form]').forEach(setupBundleForm);
|
||||
}
|
||||
|
||||
window.BundleOffers = { refreshHints: refreshHints, convertMoney: convertMoney };
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
@ -0,0 +1,36 @@
|
||||
{%- comment -%}
|
||||
App embed (§12): loads the CSS/JS once on every page, exposes the running offers to bundle-offers.js
|
||||
(window.BundleOffers), applies the design tokens, refreshes cart hints after cart changes and drives
|
||||
"Add bundle to cart" on the bundle pages served by the app proxy.
|
||||
{%- endcomment -%}
|
||||
{%- assign data = app.metafields.bundle_offers.storefront.value -%}
|
||||
{%- if data -%}
|
||||
<script type="application/json" id="bundle-offers-data">
|
||||
{
|
||||
"after": {{ data.after | json }},
|
||||
"currency": {{ shop.currency | json }},
|
||||
"moneyFormat": {{ shop.money_format | json }},
|
||||
"strings": {
|
||||
"adding": {{ 'bundle_offers.embed.adding' | t | json }},
|
||||
"added": {{ 'bundle_offers.embed.added' | t | json }},
|
||||
"error": {{ 'bundle_offers.embed.error' | t | json }}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
{%- if data.accent != blank %}--bo-accent: {{ data.accent }};{% endif %}
|
||||
--bo-radius: {{ data.radius | default: 8 }}px;
|
||||
}
|
||||
</style>
|
||||
{%- endif -%}
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "Bundle Offers",
|
||||
"target": "body",
|
||||
"javascript": "bundle-offers.js",
|
||||
"stylesheet": "bundle-offers.css",
|
||||
"settings": []
|
||||
}
|
||||
{% endschema %}
|
||||
31
extensions/bundle-offers-theme/blocks/bundles-grid.liquid
Normal file
31
extensions/bundle-offers-theme/blocks/bundles-grid.liquid
Normal file
@ -0,0 +1,31 @@
|
||||
{%- comment -%} F12: grid of all running offers (place on any page, e.g. a "Bundles" page). {%- endcomment -%}
|
||||
{%- assign data = app.metafields.bundle_offers.storefront.value -%}
|
||||
{%- assign country = localization.country.iso_code -%}
|
||||
<section class="bo-section" {{ block.shopify_attributes }}>
|
||||
{%- if block.settings.heading != blank -%}<h2 class="bo-section__title">{{ block.settings.heading }}</h2>{%- endif -%}
|
||||
{%- assign shown = 0 -%}
|
||||
<div class="bo-grid">
|
||||
{%- for offer in data.offers -%}
|
||||
{%- if offer.cc.size > 0 -%}{%- unless offer.cc contains country -%}{%- continue -%}{%- endunless -%}{%- endif -%}
|
||||
{%- assign shown = shown | plus: 1 -%}
|
||||
{% render 'bo-card', offer: offer %}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- if shown == 0 -%}
|
||||
<div class="bo-empty">
|
||||
<p>{{ 'bundle_offers.grid.empty' | t }}</p>
|
||||
<a class="bo-button" href="{{ routes.all_products_collection_url }}">{{ 'bundle_offers.grid.continue' | t }}</a>
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- if data.brand -%}<p class="bo-powered">Bundles by <a href="https://apps.shopify.com/bundle-offers" rel="nofollow">Bundle Offers</a></p>{%- endif -%}
|
||||
</section>
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "Bundles grid",
|
||||
"target": "section",
|
||||
"settings": [
|
||||
{ "type": "text", "id": "heading", "label": "Heading", "default": "Bundle offers" }
|
||||
]
|
||||
}
|
||||
{% endschema %}
|
||||
131
extensions/bundle-offers-theme/blocks/cart-hints.liquid
Normal file
131
extensions/bundle-offers-theme/blocks/cart-hints.liquid
Normal file
@ -0,0 +1,131 @@
|
||||
{%- comment -%}
|
||||
F17: up to 3 hints for running offers (Cart hints on) that the cart has started but not completed.
|
||||
Same rules as the Odoo module: fixed = some lines complete and some missing; mix = 0 < eligible < N;
|
||||
bxgy = buy quantity reached -> "you qualify, add Y more". Offers already applied are skipped.
|
||||
bundle-offers.js re-renders this block (Section Rendering) after AJAX cart changes.
|
||||
{%- endcomment -%}
|
||||
{%- liquid
|
||||
assign data = app.metafields.bundle_offers.storefront.value
|
||||
assign country = localization.country.iso_code
|
||||
assign locale = request.locale.iso_code
|
||||
assign applied = ''
|
||||
for item in cart.items
|
||||
for allocation in item.line_level_discount_allocations
|
||||
assign applied = applied | append: '|' | append: allocation.discount_application.title
|
||||
endfor
|
||||
endfor
|
||||
-%}
|
||||
{%- capture hints -%}
|
||||
{%- assign n = 0 -%}
|
||||
{%- for offer in data.offers -%}
|
||||
{%- if n >= 3 -%}{%- break -%}{%- endif -%}
|
||||
{%- if offer.f[2] != 1 -%}{%- continue -%}{%- endif -%}
|
||||
{%- if offer.cc.size > 0 -%}{%- unless offer.cc contains country -%}{%- continue -%}{%- endunless -%}{%- endif -%}
|
||||
{%- if applied contains offer.n -%}{%- continue -%}{%- endif -%}
|
||||
{%- liquid
|
||||
assign name = offer.n
|
||||
if offer.i[locale].n != blank
|
||||
assign name = offer.i[locale].n
|
||||
endif
|
||||
assign hint = ''
|
||||
if offer.t == 'fixed'
|
||||
assign have = 0
|
||||
assign missing = ''
|
||||
assign missing_count = 0
|
||||
for line in offer.l
|
||||
assign qty = 0
|
||||
for item in cart.items
|
||||
if item.product.gift_card? or item.selling_plan_allocation
|
||||
continue
|
||||
endif
|
||||
assign ipid = item.product_id | append: ''
|
||||
assign ivid = item.variant_id | append: ''
|
||||
if line.v != ''
|
||||
if ivid == line.v
|
||||
assign qty = qty | plus: item.quantity
|
||||
endif
|
||||
elsif ipid == line.p
|
||||
assign qty = qty | plus: item.quantity
|
||||
endif
|
||||
endfor
|
||||
if qty >= line.q
|
||||
assign have = have | plus: 1
|
||||
elsif missing_count < 3
|
||||
assign missing_count = missing_count | plus: 1
|
||||
if missing != ''
|
||||
assign missing = missing | append: ', '
|
||||
endif
|
||||
assign missing = missing | append: line.tt
|
||||
endif
|
||||
endfor
|
||||
if have > 0 and missing_count > 0
|
||||
assign hint = 'fixed'
|
||||
endif
|
||||
else
|
||||
if offer.t == 'mix'
|
||||
assign s = offer.sc
|
||||
assign need = offer.q
|
||||
else
|
||||
assign s = offer.bs
|
||||
assign need = offer.bq
|
||||
endif
|
||||
assign count = 0
|
||||
for item in cart.items
|
||||
if item.product.gift_card? or item.selling_plan_allocation
|
||||
continue
|
||||
endif
|
||||
assign ipid = item.product_id | append: ''
|
||||
assign ivid = item.variant_id | append: ''
|
||||
assign match = false
|
||||
if s.p contains ipid or s.v contains ivid
|
||||
assign match = true
|
||||
else
|
||||
for c in item.product.collections
|
||||
assign cid = c.id | append: ''
|
||||
if s.c contains cid
|
||||
assign match = true
|
||||
endif
|
||||
endfor
|
||||
endif
|
||||
if match
|
||||
assign count = count | plus: item.quantity
|
||||
endif
|
||||
endfor
|
||||
if offer.t == 'mix' and count > 0 and count < need
|
||||
assign hint = 'mix'
|
||||
assign k = need | minus: count
|
||||
elsif offer.t == 'bxgy' and count >= need
|
||||
assign hint = 'bxgy'
|
||||
assign k = offer.gq
|
||||
endif
|
||||
endif
|
||||
-%}
|
||||
{%- if hint == '' -%}{%- continue -%}{%- endif -%}
|
||||
{%- assign n = n | plus: 1 -%}
|
||||
{%- capture summary -%}{% render 'bo-summary', offer: offer %}{%- endcapture -%}
|
||||
{%- capture strong_name -%}<strong>{{ name | escape }}</strong>{%- endcapture -%}
|
||||
<li>
|
||||
<a class="bo-hint" href="/apps/bundles/{{ offer.h }}">
|
||||
{%- case hint -%}
|
||||
{%- when 'fixed' -%}
|
||||
{%- capture strong_items -%}<strong>{{ missing | escape }}</strong>{%- endcapture -%}
|
||||
{{- 'bundle_offers.hints.fixed_html' | t: items: strong_items, name: strong_name, summary: summary -}}
|
||||
{%- when 'mix' -%}{{- 'bundle_offers.hints.mix_html' | t: count: k, name: strong_name, summary: summary -}}
|
||||
{%- else -%}{{- 'bundle_offers.hints.bxgy_html' | t: count: k, name: strong_name, summary: summary -}}
|
||||
{%- endcase -%}
|
||||
</a>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
{%- endcapture -%}
|
||||
<div class="bo-hints" data-bo-hints="{{ block.id }}" {{ block.shopify_attributes }}>
|
||||
{%- if hints != blank -%}<ul role="list">{{ hints }}</ul>{%- endif -%}
|
||||
</div>
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "Bundle cart hints",
|
||||
"target": "section",
|
||||
"enabled_on": { "templates": ["cart"] },
|
||||
"settings": []
|
||||
}
|
||||
{% endschema %}
|
||||
39
extensions/bundle-offers-theme/blocks/homepage-strip.liquid
Normal file
39
extensions/bundle-offers-theme/blocks/homepage-strip.liquid
Normal file
@ -0,0 +1,39 @@
|
||||
{%- comment -%} F15: "Festival offers / Bundle & save" strip, up to 8 offers with Homepage on. Place it right after the hero. {%- endcomment -%}
|
||||
{%- assign data = app.metafields.bundle_offers.storefront.value -%}
|
||||
{%- assign country = localization.country.iso_code -%}
|
||||
{%- capture cards -%}
|
||||
{%- assign n = 0 -%}
|
||||
{%- for offer in data.offers -%}
|
||||
{%- if n >= 8 -%}{%- break -%}{%- endif -%}
|
||||
{%- if offer.f[0] != 1 -%}{%- continue -%}{%- endif -%}
|
||||
{%- if offer.cc.size > 0 -%}{%- unless offer.cc contains country -%}{%- continue -%}{%- endunless -%}{%- endif -%}
|
||||
{%- assign n = n | plus: 1 -%}
|
||||
<li class="bo-strip__item">{% render 'bo-card', offer: offer %}</li>
|
||||
{%- endfor -%}
|
||||
{%- endcapture -%}
|
||||
{%- if cards != blank -%}
|
||||
<section class="bo-section bo-strip" aria-labelledby="bo-strip-{{ block.id }}" {{ block.shopify_attributes }}>
|
||||
<div class="bo-strip__head">
|
||||
<div>
|
||||
{%- assign heading = block.settings.heading -%}
|
||||
{%- if heading == blank -%}{%- assign heading = 'bundle_offers.strip.heading' | t -%}{%- endif -%}
|
||||
<h2 class="bo-section__title" id="bo-strip-{{ block.id }}">{{ heading }}</h2>
|
||||
{%- if block.settings.subheading != blank -%}<p class="bo-section__sub">{{ block.settings.subheading }}</p>{%- endif -%}
|
||||
</div>
|
||||
<a class="bo-link" href="{{ block.settings.all_url | default: '/apps/bundles' }}">{{ 'bundle_offers.strip.view_all' | t }}</a>
|
||||
</div>
|
||||
<ul class="bo-strip__track" role="list">{{ cards }}</ul>
|
||||
</section>
|
||||
{%- endif -%}
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "Bundle offers strip",
|
||||
"target": "section",
|
||||
"settings": [
|
||||
{ "type": "text", "id": "heading", "label": "Heading", "default": "Festival offers" },
|
||||
{ "type": "text", "id": "subheading", "label": "Subheading", "default": "Bundle & save" },
|
||||
{ "type": "url", "id": "all_url", "label": "\"View all\" link (default: /apps/bundles)" }
|
||||
]
|
||||
}
|
||||
{% endschema %}
|
||||
58
extensions/bundle-offers-theme/blocks/product-box.liquid
Normal file
58
extensions/bundle-offers-theme/blocks/product-box.liquid
Normal file
@ -0,0 +1,58 @@
|
||||
{%- comment -%}
|
||||
F16: "Save with a bundle" on product pages: up to 3 running offers (Product pages on) that contain this
|
||||
product, as a fixed-bundle line, or a product / variant / collection in any scope.
|
||||
{%- endcomment -%}
|
||||
{%- liquid
|
||||
assign data = app.metafields.bundle_offers.storefront.value
|
||||
assign country = localization.country.iso_code
|
||||
assign pid = product.id | append: ''
|
||||
assign vids = product.variants | map: 'id' | join: ',' | split: ','
|
||||
assign cids = product.collections | map: 'id' | join: ',' | split: ','
|
||||
assign locale = request.locale.iso_code
|
||||
-%}
|
||||
{%- capture rows -%}
|
||||
{%- assign n = 0 -%}
|
||||
{%- for offer in data.offers -%}
|
||||
{%- if n >= 3 -%}{%- break -%}{%- endif -%}
|
||||
{%- if offer.f[1] != 1 -%}{%- continue -%}{%- endif -%}
|
||||
{%- if offer.cc.size > 0 -%}{%- unless offer.cc contains country -%}{%- continue -%}{%- endunless -%}{%- endif -%}
|
||||
{%- assign hit = false -%}
|
||||
{%- for line in offer.l -%}
|
||||
{%- if line.p == pid -%}{%- assign hit = true -%}{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- assign keys = 'sc,bs,gs' | split: ',' -%}
|
||||
{%- for key in keys -%}
|
||||
{%- assign s = offer[key] -%}
|
||||
{%- if s -%}
|
||||
{%- if s.p contains pid -%}{%- assign hit = true -%}{%- endif -%}
|
||||
{%- for v in vids -%}{%- if s.v contains v -%}{%- assign hit = true -%}{%- endif -%}{%- endfor -%}
|
||||
{%- for c in cids -%}{%- if s.c contains c -%}{%- assign hit = true -%}{%- endif -%}{%- endfor -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- unless hit -%}{%- continue -%}{%- endunless -%}
|
||||
{%- assign n = n | plus: 1 -%}
|
||||
{%- assign name = offer.n -%}
|
||||
{%- if offer.i[locale].n != blank -%}{%- assign name = offer.i[locale].n -%}{%- endif -%}
|
||||
<li>
|
||||
<a class="bo-box__row" href="/apps/bundles/{{ offer.h }}">
|
||||
<span class="bo-box__name">{{ name }}</span>
|
||||
<span class="bo-box__deal">{% render 'bo-summary', offer: offer %}</span>
|
||||
</a>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
{%- endcapture -%}
|
||||
{%- if rows != blank -%}
|
||||
<aside class="bo-box" aria-labelledby="bo-box-{{ block.id }}" {{ block.shopify_attributes }}>
|
||||
<h2 class="bo-box__title" id="bo-box-{{ block.id }}">{{ 'bundle_offers.product_box.heading' | t }}</h2>
|
||||
<ul role="list">{{ rows }}</ul>
|
||||
</aside>
|
||||
{%- endif -%}
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "Save with a bundle",
|
||||
"target": "section",
|
||||
"enabled_on": { "templates": ["product"] },
|
||||
"settings": []
|
||||
}
|
||||
{% endschema %}
|
||||
53
extensions/bundle-offers-theme/locales/en.default.json
Normal file
53
extensions/bundle-offers-theme/locales/en.default.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"bundle_offers": {
|
||||
"summary": {
|
||||
"bxgy_free": "Buy {{ buy }}, get {{ get }} free",
|
||||
"bxgy_pct": "Buy {{ buy }}, get {{ get }} at {{ pct }}% off",
|
||||
"mix_pct": "{{ pct }}% off any {{ n }}",
|
||||
"fixed_pct": "Save {{ pct }}% on the bundle",
|
||||
"mix_price_html": "Any {{ n }} for {{ price }}",
|
||||
"mix_amount_html": "{{ off }} off any {{ n }}",
|
||||
"fixed_price_html": "Bundle price {{ price }}",
|
||||
"fixed_amount_html": "Save {{ off }} on the bundle"
|
||||
},
|
||||
"card": {
|
||||
"items": {
|
||||
"one": "{{ count }} item",
|
||||
"other": "{{ count }} items"
|
||||
},
|
||||
"shop": "Shop the bundle",
|
||||
"regular_price": "Regular price",
|
||||
"bundle_price": "Bundle price",
|
||||
"ends_html": "Ends {{ date }}"
|
||||
},
|
||||
"strip": {
|
||||
"heading": "Festival offers",
|
||||
"subheading": "Bundle & save",
|
||||
"view_all": "View all bundles"
|
||||
},
|
||||
"grid": {
|
||||
"heading": "Bundle offers",
|
||||
"empty": "No bundle offers right now. Check back for our festival specials!",
|
||||
"continue": "Continue shopping"
|
||||
},
|
||||
"product_box": {
|
||||
"heading": "Save with a bundle"
|
||||
},
|
||||
"hints": {
|
||||
"fixed_html": "Add {{ items }} to get {{ name }} ({{ summary }}).",
|
||||
"mix_html": {
|
||||
"one": "Add {{ count }} more eligible item to get {{ name }} ({{ summary }}).",
|
||||
"other": "Add {{ count }} more eligible items to get {{ name }} ({{ summary }})."
|
||||
},
|
||||
"bxgy_html": {
|
||||
"one": "You qualify for {{ name }}: add {{ count }} more eligible item ({{ summary }}).",
|
||||
"other": "You qualify for {{ name }}: add {{ count }} more eligible items ({{ summary }})."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"adding": "Adding…",
|
||||
"added": "Added to cart",
|
||||
"error": "These items couldn't be added. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
53
extensions/bundle-offers-theme/locales/fr.json
Normal file
53
extensions/bundle-offers-theme/locales/fr.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"bundle_offers": {
|
||||
"summary": {
|
||||
"bxgy_free": "{{ buy }} achetés, {{ get }} offert(s)",
|
||||
"bxgy_pct": "{{ buy }} achetés, {{ get }} à -{{ pct }} %",
|
||||
"mix_pct": "-{{ pct }} % sur {{ n }} au choix",
|
||||
"fixed_pct": "Économisez {{ pct }} % sur l'ensemble",
|
||||
"mix_price_html": "{{ n }} au choix pour {{ price }}",
|
||||
"mix_amount_html": "{{ off }} de rabais sur {{ n }} au choix",
|
||||
"fixed_price_html": "Prix de l'ensemble {{ price }}",
|
||||
"fixed_amount_html": "Économisez {{ off }} sur l'ensemble"
|
||||
},
|
||||
"card": {
|
||||
"items": {
|
||||
"one": "{{ count }} article",
|
||||
"other": "{{ count }} articles"
|
||||
},
|
||||
"shop": "Voir l'ensemble",
|
||||
"regular_price": "Prix régulier",
|
||||
"bundle_price": "Prix de l'ensemble",
|
||||
"ends_html": "Se termine le {{ date }}"
|
||||
},
|
||||
"strip": {
|
||||
"heading": "Offres de fête",
|
||||
"subheading": "Ensembles et économies",
|
||||
"view_all": "Voir tous les ensembles"
|
||||
},
|
||||
"grid": {
|
||||
"heading": "Offres groupées",
|
||||
"empty": "Aucune offre groupée pour le moment. Revenez bientôt pour nos offres de fête!",
|
||||
"continue": "Continuer vos achats"
|
||||
},
|
||||
"product_box": {
|
||||
"heading": "Économisez avec un ensemble"
|
||||
},
|
||||
"hints": {
|
||||
"fixed_html": "Ajoutez {{ items }} pour obtenir {{ name }} ({{ summary }}).",
|
||||
"mix_html": {
|
||||
"one": "Ajoutez {{ count }} article admissible de plus pour obtenir {{ name }} ({{ summary }}).",
|
||||
"other": "Ajoutez {{ count }} articles admissibles de plus pour obtenir {{ name }} ({{ summary }})."
|
||||
},
|
||||
"bxgy_html": {
|
||||
"one": "Vous êtes admissible à {{ name }} : ajoutez {{ count }} article admissible ({{ summary }}).",
|
||||
"other": "Vous êtes admissible à {{ name }} : ajoutez {{ count }} articles admissibles ({{ summary }})."
|
||||
}
|
||||
},
|
||||
"embed": {
|
||||
"adding": "Ajout…",
|
||||
"added": "Ajouté au panier",
|
||||
"error": "Impossible d'ajouter ces articles. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
}
|
||||
2
extensions/bundle-offers-theme/shopify.extension.toml
Normal file
2
extensions/bundle-offers-theme/shopify.extension.toml
Normal file
@ -0,0 +1,2 @@
|
||||
name = "Bundle Offers storefront"
|
||||
type = "theme"
|
||||
86
extensions/bundle-offers-theme/snippets/bo-card.liquid
Normal file
86
extensions/bundle-offers-theme/snippets/bo-card.liquid
Normal file
@ -0,0 +1,86 @@
|
||||
{% doc %}
|
||||
Offer card (F19): image or a 2x2 grid of product pictures, badge, name, deal, and for fixed bundles the
|
||||
bundle price, the struck-through regular price and the number of items; end date; link to the bundle page.
|
||||
Fixed-bundle prices come from live product prices (all_products, presentment currency); bundle-offers.js
|
||||
recomputes $-off / bundle-price deals when the customer shops in another currency.
|
||||
|
||||
@param {object} offer - An offer from the app's storefront data
|
||||
@param {string} [heading_tag] - h2 or h3 (default h3)
|
||||
@example
|
||||
{% render 'bo-card', offer: offer %}
|
||||
{% enddoc %}
|
||||
{%- liquid
|
||||
assign url = '/apps/bundles/' | append: offer.h
|
||||
assign tag = heading_tag | default: 'h3'
|
||||
assign locale = request.locale.iso_code
|
||||
assign name = offer.n
|
||||
assign badge = offer.b
|
||||
if offer.i[locale].n != blank
|
||||
assign name = offer.i[locale].n
|
||||
endif
|
||||
if offer.i[locale].b != blank
|
||||
assign badge = offer.i[locale].b
|
||||
endif
|
||||
|
||||
assign regular = 0
|
||||
if offer.t == 'fixed'
|
||||
for line in offer.l
|
||||
assign p = all_products[line.h]
|
||||
if p.id
|
||||
assign price = p.price
|
||||
if line.v != ''
|
||||
for variant in p.variants
|
||||
assign vid = variant.id | append: ''
|
||||
if vid == line.v
|
||||
assign price = variant.price
|
||||
endif
|
||||
endfor
|
||||
endif
|
||||
assign line_total = price | times: line.q
|
||||
assign regular = regular | plus: line_total
|
||||
endif
|
||||
endfor
|
||||
assign dv_cents = offer.dv | times: 100
|
||||
case offer.dt
|
||||
when 'percent'
|
||||
assign saving = regular | times: offer.dv | divided_by: 100.0 | round
|
||||
when 'amount'
|
||||
assign saving = dv_cents | at_most: regular
|
||||
else
|
||||
assign saving = regular | minus: dv_cents | at_least: 0
|
||||
endcase
|
||||
assign bundle = regular | minus: saving
|
||||
endif
|
||||
-%}
|
||||
<article class="bo-card">
|
||||
<a class="bo-card__media" href="{{ url }}" tabindex="-1" aria-hidden="true">
|
||||
{%- if offer.img != blank -%}
|
||||
<img src="{{ offer.img }}" alt="" loading="lazy" width="800" height="600">
|
||||
{%- elsif offer.th.size > 0 -%}
|
||||
<span class="bo-card__thumbs bo-card__thumbs--{{ offer.th.size | at_most: 4 }}">
|
||||
{%- for src in offer.th limit: 4 -%}<img src="{{ src }}" alt="" loading="lazy" width="200" height="200">{%- endfor -%}
|
||||
</span>
|
||||
{%- endif -%}
|
||||
</a>
|
||||
{%- if badge != blank -%}<p class="bo-stub"><span>{{ badge }}</span></p>{%- endif -%}
|
||||
<div class="bo-card__body">
|
||||
<{{ tag }} class="bo-card__title"><a href="{{ url }}">{{ name }}</a></{{ tag }}>
|
||||
<p class="bo-card__deal">{% render 'bo-summary', offer: offer %}</p>
|
||||
{%- if offer.t == 'fixed' and regular > 0 -%}
|
||||
<p class="bo-card__prices" data-bo-fixed data-regular="{{ regular }}" data-dt="{{ offer.dt }}" data-dv="{{ offer.dv }}">
|
||||
<span class="bo-visually-hidden">{{ 'bundle_offers.card.bundle_price' | t }}</span>
|
||||
<span class="bo-card__now" data-bo-bundle>{{ bundle | money }}</span>
|
||||
{%- if saving > 0 -%}
|
||||
<span class="bo-visually-hidden">{{ 'bundle_offers.card.regular_price' | t }}</span>
|
||||
<s class="bo-card__was" data-bo-regular>{{ regular | money }}</s>
|
||||
{%- endif -%}
|
||||
<span class="bo-card__count">{{ 'bundle_offers.card.items' | t: count: offer.ni }}</span>
|
||||
</p>
|
||||
{%- endif -%}
|
||||
{%- if offer.e != blank -%}
|
||||
{%- assign ends = offer.e | time_tag: format: 'date' -%}
|
||||
<p class="bo-card__ends">{{ 'bundle_offers.card.ends_html' | t: date: ends }}</p>
|
||||
{%- endif -%}
|
||||
<a class="bo-button" href="{{ url }}">{{ 'bundle_offers.card.shop' | t }}</a>
|
||||
</div>
|
||||
</article>
|
||||
32
extensions/bundle-offers-theme/snippets/bo-summary.liquid
Normal file
32
extensions/bundle-offers-theme/snippets/bo-summary.liquid
Normal file
@ -0,0 +1,32 @@
|
||||
{% doc %}
|
||||
Deal summary of a bundle offer (brief §5), translated. Money values are in shop currency; they are marked
|
||||
with data-bo-money so bundle-offers.js converts them when the customer shops in another currency.
|
||||
|
||||
@param {object} offer - An offer from the app's storefront data
|
||||
@example
|
||||
{% render 'bo-summary', offer: offer %}
|
||||
{% enddoc %}
|
||||
{%- assign dv_cents = offer.dv | times: 100 | round -%}
|
||||
{%- capture formatted -%}<span data-bo-money="{{ offer.dv }}">{{ dv_cents | money }}</span>{%- endcapture -%}
|
||||
{%- assign pct = offer.dv | round: 2 -%}
|
||||
{%- case offer.t -%}
|
||||
{%- when 'bxgy' -%}
|
||||
{%- if offer.gp >= 100 -%}
|
||||
{{- 'bundle_offers.summary.bxgy_free' | t: buy: offer.bq, get: offer.gq -}}
|
||||
{%- else -%}
|
||||
{%- assign gp = offer.gp | round: 2 -%}
|
||||
{{- 'bundle_offers.summary.bxgy_pct' | t: buy: offer.bq, get: offer.gq, pct: gp -}}
|
||||
{%- endif -%}
|
||||
{%- when 'mix' -%}
|
||||
{%- case offer.dt -%}
|
||||
{%- when 'fixed_price' -%}{{- 'bundle_offers.summary.mix_price_html' | t: n: offer.q, price: formatted -}}
|
||||
{%- when 'amount' -%}{{- 'bundle_offers.summary.mix_amount_html' | t: n: offer.q, off: formatted -}}
|
||||
{%- else -%}{{- 'bundle_offers.summary.mix_pct' | t: n: offer.q, pct: pct -}}
|
||||
{%- endcase -%}
|
||||
{%- else -%}
|
||||
{%- case offer.dt -%}
|
||||
{%- when 'fixed_price' -%}{{- 'bundle_offers.summary.fixed_price_html' | t: price: formatted -}}
|
||||
{%- when 'amount' -%}{{- 'bundle_offers.summary.fixed_amount_html' | t: off: formatted -}}
|
||||
{%- else -%}{{- 'bundle_offers.summary.fixed_pct' | t: pct: pct -}}
|
||||
{%- endcase -%}
|
||||
{%- endcase -%}
|
||||
13289
package-lock.json
generated
Normal file
13289
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user