local-storefront 0.2.0 → 0.3.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1299 -0
- package/README.md +14 -9
- package/bin/lsf.js +4 -1
- package/docs/README.md +59 -0
- package/docs/admin-reference.md +1406 -0
- package/docs/admin.md +1751 -0
- package/docs/agents.md +366 -0
- package/docs/architecture.md +303 -0
- package/docs/checkout.md +1609 -0
- package/docs/cli.md +873 -0
- package/docs/compatibility.md +475 -0
- package/docs/control-api.md +251 -0
- package/docs/data-format.md +943 -0
- package/docs/data-reference.md +1247 -0
- package/docs/formatting.md +119 -0
- package/docs/performance.md +266 -0
- package/docs/quickstart-admin-api.md +211 -0
- package/docs/quickstart-app.md +271 -0
- package/docs/quickstart-functions.md +207 -0
- package/docs/quickstart-theme.md +177 -0
- package/docs/storefront.md +210 -0
- package/docs/strict-liquid.md +178 -0
- package/docs/testing.md +590 -0
- package/llms.txt +39 -0
- package/package.json +11 -8
package/docs/checkout.md
ADDED
|
@@ -0,0 +1,1609 @@
|
|
|
1
|
+
# Checkout
|
|
2
|
+
|
|
3
|
+
Run the Shopify Functions and show the checkout and customer account UI extensions of a Shopify
|
|
4
|
+
app, on your machine.
|
|
5
|
+
local-storefront serves a fake checkout for it: no store, no payment, and no network except the
|
|
6
|
+
requests of the Functions that have [network access](#network-access). It is a way to
|
|
7
|
+
see what your Functions do to a cart, and how your extensions look and behave, not a copy of
|
|
8
|
+
Shopify's checkout.
|
|
9
|
+
|
|
10
|
+
## Run it
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx local-storefront app dev --app ../my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Open <http://127.0.0.1:9292/>: the [home page](#the-home-page) lists what is wrong first, then
|
|
17
|
+
the [checkout scenarios](#scenarios) and the extensions of the app, and opens a checkout for each. <http://127.0.0.1:9292/checkout>
|
|
18
|
+
is the checkout of the cart of the session, which the store data defines (`session.cart`, see
|
|
19
|
+
[data format](data-format.md#customers-and-sessions)). It needs no theme.
|
|
20
|
+
|
|
21
|
+
The pages need a recent browser: Chrome 123+, Safari 17.2+ or Firefox 138+ (they import JSON
|
|
22
|
+
modules).
|
|
23
|
+
|
|
24
|
+
With a theme, `serve` does the same and the cart form of the theme leads to that checkout. The
|
|
25
|
+
theme owns `/`, so the home page is at <http://127.0.0.1:9292/__lsf/checkouts>, and the server
|
|
26
|
+
prints its address when it starts:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx local-storefront serve --app ../my-app
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
No scenarios yet? `npx local-storefront app init --app ../my-app` writes a set made for
|
|
33
|
+
the extensions of the app, see [scenarios](#scenarios).
|
|
34
|
+
|
|
35
|
+
In the app's folder, `--app` can be left out: `lsf app dev`, `lsf app function run` and the other
|
|
36
|
+
commands of an app take the app the current folder is in. The store data is `--data`, else the
|
|
37
|
+
`shopify-local/` of the current folder (or of the theme), else the app's
|
|
38
|
+
(`<app>/shopify-local`), else the built-in demo store. One that `lsf init` creates while the
|
|
39
|
+
server runs is read at its next reload.
|
|
40
|
+
|
|
41
|
+
The app is a directory with `shopify.app.toml` and `extensions/*/shopify.extension.toml`. API
|
|
42
|
+
versions 2026-10 and 2027-01 are supported. Functions run from their built Wasm module and
|
|
43
|
+
extensions from their built bundle (`dist/function.wasm`, `dist/<handle>.js`). lsf builds them
|
|
44
|
+
as `shopify app build` does, without Node and without Shopify's CLI
|
|
45
|
+
([what it builds](#what-lsf-builds-without-node)): `lsf app build`, or `--build` before it
|
|
46
|
+
serves:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx local-storefront app build --app ../my-app # every extension
|
|
50
|
+
npx local-storefront app dev --app ../my-app --build
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Coming from Shopify's CLI
|
|
54
|
+
|
|
55
|
+
| Shopify CLI | lsf | What lsf does not do |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `shopify app dev` | `lsf app dev --watch`: the checkout, the customer account and the Admin API, built again at each save ([watch mode](#watch-mode)) | No development store, tunnel or Partner account. The app's web server is yours to start: give its URL with `--app-url`. |
|
|
58
|
+
| `shopify app build` | `lsf app build` | `wasm-opt` is not run on a Rust Function ([Rust Functions](#rust-functions)). |
|
|
59
|
+
| `shopify app function build` | `lsf app function build` | `@shopify/shopify_function` 1.x ([JavaScript Functions](#javascript-and-typescript-functions)). |
|
|
60
|
+
| `shopify app function run` | `lsf app function run --input <file>` (or `-`, or a folder of fixtures), `--export <name>`, `--format json`; with `--scenario`, on an input lsf builds from the store data ([run a Function](#run-a-function-without-a-server)). As Shopify's, it builds the Function first when its files changed after its module was built. | |
|
|
61
|
+
| `shopify app function replay` | `lsf app function run --scenario <name> --save-input <file>`, then `--input <file>` | No run of a store to replay: lsf does not read Shopify's logs. |
|
|
62
|
+
| `shopify app function typegen` | `lsf app function typegen` | A Rust Function's types come from the `typegen` macro, at its build. |
|
|
63
|
+
| `shopify app function schema` | `lsf app function schema` | The schemas of fulfillment constraints, order routing and pickup generators. |
|
|
64
|
+
| `shopify app webhook trigger` | `lsf app webhook trigger` ([Admin API](admin.md)) | |
|
|
65
|
+
| `shopify app generate extension` | Not covered: copy an extension of the [playground](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground/extensions) ([add a JavaScript Function](#add-a-javascript-function)). | |
|
|
66
|
+
| `shopify app init`, `deploy`, `release`, `versions`, `env`, `config`, `info` | Not covered: they act on Shopify's servers. `lsf app init` writes checkout scenarios, not an app. | |
|
|
67
|
+
|
|
68
|
+
### What lsf builds without Node
|
|
69
|
+
|
|
70
|
+
| Extension | Built by lsf when | Output |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| A Function with a build command (Rust, …) | always: the command runs in its folder, then a module that calls the Function Wasm API by name is linked to it ([below](#rust-functions)) | its module |
|
|
73
|
+
| A Function in JavaScript or TypeScript (`src/index.js`, `src/index.ts`), no build command | always ([below](#javascript-and-typescript-functions)) | `dist/function.wasm` |
|
|
74
|
+
| A checkout UI extension whose targets name their sources (`module`), and the sources are there | always ([below](#checkout-ui-extensions)) | `dist/<handle>.js` |
|
|
75
|
+
| A checkout UI extension with only its bundle | never: the bundle is used as it is | |
|
|
76
|
+
|
|
77
|
+
The npm packages they import come from the app's `node_modules`, when it has them. Without, lsf
|
|
78
|
+
has its own copy of those Shopify's templates use: `@shopify/shopify_function` 2.0.1, `preact`
|
|
79
|
+
10.29.8, `@preact/signals` 2.11.4 and `@shopify/ui-extensions` 2026.10.0 (MIT, but
|
|
80
|
+
`@shopify/shopify_function`: Apache-2.0), written once in the user's cache folder
|
|
81
|
+
(`~/Library/Caches/lsf/` on macOS, `~/.cache/lsf/` on Linux). Any other package needs a
|
|
82
|
+
`node_modules`.
|
|
83
|
+
|
|
84
|
+
### JavaScript and TypeScript Functions
|
|
85
|
+
|
|
86
|
+
A Function written in JavaScript or TypeScript (`src/index.js` or `src/index.ts`) with no build
|
|
87
|
+
command, as `shopify app generate extension` makes it, is built by lsf the way
|
|
88
|
+
`shopify app function build` builds it, without Node and without Shopify's CLI:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx local-storefront app function build --app ../my-app # every Function
|
|
92
|
+
npx local-storefront app function build discount --app ../my-app
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`--build` and `--watch` build it the same way, and `lsf app function typegen` writes its types
|
|
96
|
+
alone, as `shopify app function typegen` does (the `typegen` script of Shopify's templates'
|
|
97
|
+
`package.json`). The steps are the CLI's:
|
|
98
|
+
|
|
99
|
+
1. **Types**: the `codegen` section of its `package.json` (graphql-codegen's config) is written
|
|
100
|
+
as graphql-codegen writes it (`generated/api.ts`), from `schema.graphql`, or, when the
|
|
101
|
+
Function has none, lsf's schema of its API version.
|
|
102
|
+
2. **One JavaScript file** (`dist/function.js`), as esbuild bundles it: TypeScript removed, the
|
|
103
|
+
modules it imports (with `node_modules`, when there is one), enum members inlined, and what
|
|
104
|
+
nothing uses left out. `process.env.NAME` is the value of `NAME` in the environment or in the
|
|
105
|
+
app's `.env`. `@shopify/shopify_function` (2.x) need not be installed: lsf has its own copy
|
|
106
|
+
of the part a Function runs.
|
|
107
|
+
3. **The Wasm module**, as Javy writes it for Shopify's plugin: for the same JavaScript, the
|
|
108
|
+
same bytes. It holds the QuickJS bytecode of the Function and imports the JavaScript engine
|
|
109
|
+
from the plugin, which lsf runs it with, as Shopify does.
|
|
110
|
+
|
|
111
|
+
The module costs the instructions the CLI's would, within a few hundred. What lsf does not
|
|
112
|
+
build: `import()`, a `require` of an ES module, and Functions written with
|
|
113
|
+
`@shopify/shopify_function` 1.x (`shopify app build` builds those).
|
|
114
|
+
|
|
115
|
+
#### Add a JavaScript Function
|
|
116
|
+
|
|
117
|
+
`shopify app generate extension` needs a Partner account and the network. Without it, copy a
|
|
118
|
+
Function of the playground:
|
|
119
|
+
[`discount-typescript`](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground/extensions/discount-typescript)
|
|
120
|
+
or [`validation-javascript`](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground/extensions/validation-javascript).
|
|
121
|
+
A Function is a folder of `extensions/`:
|
|
122
|
+
|
|
123
|
+
| File | What it holds |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `shopify.extension.toml` | `type = "function"`, its `handle`, a `[[extensions.targeting]]` with its `target`, `input_query` and `export`, and `[extensions.build]` with `command = ""` and `path = "dist/function.wasm"`. |
|
|
126
|
+
| `package.json` | `"@shopify/shopify_function": "2.0.1"` in `dependencies`: lsf needs it declared, not installed. The `codegen` section, for the types. |
|
|
127
|
+
| `src/index.js` (or `.ts`) | `export * from "./<target>";`, the target with `_` for `.`: `./cart_lines_discounts_generate_run` for a discount. |
|
|
128
|
+
| `src/<target>.graphql`, `src/<target>.js` | The input query, and the function the `export` names, in camel case (`cart-validations-generate-run`: `cartValidationsGenerateRun`). |
|
|
129
|
+
|
|
130
|
+
Then build it at each save with `npx local-storefront app dev --watch`. A discount Function runs
|
|
131
|
+
only for a discount that names it, and the scenarios written before it leave it out: see
|
|
132
|
+
[scenarios](#scenarios).
|
|
133
|
+
|
|
134
|
+
### The schema of a Function
|
|
135
|
+
|
|
136
|
+
A Function's `schema.graphql` is the GraphQL schema of its API at its `api_version`: the Rust
|
|
137
|
+
`shopify_function` crate generates its types from it (`#[typegen("schema.graphql")]`), and a
|
|
138
|
+
JavaScript Function's graphql-codegen too. `shopify app function schema` writes it from
|
|
139
|
+
Shopify's servers; lsf writes it from the schemas it carries, offline:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
cd extensions/payment-rules && npx local-storefront app function schema # in the Function's folder
|
|
143
|
+
npx local-storefront app function schema payment-rules # or by handle, from the app
|
|
144
|
+
npx local-storefront app function schema payment-rules --stdout > other.graphql
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
lsf carries the schemas of the checkout's Function APIs (cart transform, discounts, delivery and
|
|
148
|
+
payment customizations, cart and checkout validations) at 2026-10 and 2027-01: the ones it runs
|
|
149
|
+
the Functions with. Fulfillment constraints, order routing and pickup generators are not among
|
|
150
|
+
them. After changing `api_version`, write it again.
|
|
151
|
+
|
|
152
|
+
### Rust Functions
|
|
153
|
+
|
|
154
|
+
`cargo build` writes a module that calls the Function Wasm API of the host by name
|
|
155
|
+
(`shopify_function_v2::shopify_function_input_get`); `shopify app build` links it to the host's
|
|
156
|
+
provider with Shopify's `shopify-function-trampoline` after the build command. lsf runs that same
|
|
157
|
+
trampoline, as a library, at the version Shopify's CLI 5.0.0 runs (2.0.1): after the build command
|
|
158
|
+
of `--build`, `--watch` and `lsf app function build`, the module is the one the CLI would write, to the
|
|
159
|
+
byte. A module already linked is left as it is. Modules of the first version of the API
|
|
160
|
+
(`shopify_function_v1`) are refused, as by the trampoline: update the `shopify_function` crate.
|
|
161
|
+
|
|
162
|
+
The `typegen` macro of the `shopify_function` crate reads the input queries and
|
|
163
|
+
`schema.graphql` without telling cargo, so a change to one of them alone compiles nothing. When
|
|
164
|
+
one is newer than the module, lsf's builds (`--build`, `--watch`, `lsf app function build`)
|
|
165
|
+
touch `src/main.rs` and `src/lib.rs` first (their time, not their text), and cargo compiles the
|
|
166
|
+
crate again: `↻ delivery-rules: src/run.graphql changed: src/main.rs touched, for cargo to
|
|
167
|
+
compile the crate again`. A compiler cache (sccache, a `RUSTC_WRAPPER`) may still answer the old
|
|
168
|
+
build, as it does not see those files either: the watch then says that the module did not
|
|
169
|
+
change.
|
|
170
|
+
|
|
171
|
+
lsf does not run `wasm-opt -Oz`, which the CLI runs on a module before it links it: on the
|
|
172
|
+
playground's Rust Functions it takes 5 to 6% off the module's size, and 0.25 to 0.5% off the
|
|
173
|
+
instructions a run costs.
|
|
174
|
+
|
|
175
|
+
### Checkout UI extensions
|
|
176
|
+
|
|
177
|
+
A checkout or customer account UI extension whose targets name their sources
|
|
178
|
+
(`module = "./src/Checkout.jsx"`), in JavaScript, TypeScript or JSX, with Preact as Shopify's
|
|
179
|
+
templates or another runtime, is bundled by lsf the way `shopify app build` bundles it, without
|
|
180
|
+
Node:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npx local-storefront app build banner --app ../my-app
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`--build` builds it too, and `--watch` builds it again when its files change, in a moment, then
|
|
187
|
+
reloads the open pages; at start, it builds those whose bundle is missing or older than their
|
|
188
|
+
files. As Shopify's CLI does:
|
|
189
|
+
|
|
190
|
+
- **The entry**: from API version 2025-10, each target's module is imported and its default
|
|
191
|
+
export registered with `shopify.extend('<target>', …)`; before, each module is imported.
|
|
192
|
+
- **JSX** calls the automatic runtime of the `jsxImportSource` of the closest `tsconfig.json`
|
|
193
|
+
(`preact` in Shopify's templates; `react` without one), in `.jsx`, `.tsx` and `.js` files. A
|
|
194
|
+
Preact extension without its `tsconfig.json` fails on `react/jsx-runtime`, and the error says
|
|
195
|
+
what to add: `{"compilerOptions": {"jsx": "react-jsx", "jsxImportSource": "preact"}}`.
|
|
196
|
+
- **The bundle** is one minified script (`dist/<handle>.js`): the modules it imports, what nothing
|
|
197
|
+
uses left out, `process.env.NODE_ENV` `production` and the other values of `process.env` from
|
|
198
|
+
the environment or the app's `.env`.
|
|
199
|
+
|
|
200
|
+
The bundle does what the CLI's does: the playground's tests draw both at their target and act on
|
|
201
|
+
them, and compare. It is not the same text (each is minified by its own tool), it is not lowered
|
|
202
|
+
to ES2015 (the page runs in a browser that needs none of it), and a target's `should_render`
|
|
203
|
+
module is not built (the page does not run it). A `.graphql` import is an error.
|
|
204
|
+
|
|
205
|
+
What is wrong with the app (a missing build, an unknown Function target, a checkout UI target
|
|
206
|
+
the page has no place for, an `owner_type` that is missing or not one of Shopify's) is printed
|
|
207
|
+
when the server starts (`app: …`), and is in the `diagnostics` of the state and on the home
|
|
208
|
+
page. A missing build
|
|
209
|
+
(``no Wasm module at …: build it with `npx local-storefront app build <handle>` ``, `not built`):
|
|
210
|
+
`npx local-storefront app build`, or `--build`. With `--build`,
|
|
211
|
+
`app dev`, `serve` and `app function run` build first, then print what is still wrong: a
|
|
212
|
+
module the build wrote is not reported missing.
|
|
213
|
+
|
|
214
|
+
| Option | What it does |
|
|
215
|
+
|---|---|
|
|
216
|
+
| `--app <dir>` (`LSF_APP`) | The app. The commands of `lsf app` default to the app the current folder is in. |
|
|
217
|
+
| `--extension <handle>` | Uses only this extension, a Function or a checkout UI extension. Repeat it for several. Without it, every extension of the app is used. |
|
|
218
|
+
| `--build` | Builds the extensions first, as `lsf app build` does: each Function (its build command, or lsf's build of a JavaScript Function) and the UI extensions that name their sources. With `--no-functions`, the UI extensions only. |
|
|
219
|
+
| `--no-functions` | Serves the UI extensions and the Admin API, and runs no Function: for a backend's tests ([Admin API](admin.md#place-an-order)). |
|
|
220
|
+
| `--only <parts>` (`LSF_ONLY`) | Runs only these parts of lsf: `app dev` runs `checkout,admin`, `serve --app` adds `theme` ([what runs](cli.md#what-runs)). Without `checkout`, the home page, `/__lsf/checkout` and `/__lsf/checkouts` answer `404` with an `X-LSF-Reason`, and so do `/checkout` and `/checkouts/…` without a theme; with one, `/checkout` is the theme's summary of the cart. Without `admin`, `complete` records no order in the Admin API and sends no webhook. |
|
|
221
|
+
| `--watch` | Develops the app as `shopify app dev` does: builds a Function again when its files change, and reloads the open pages. With `--build`, builds every Function at start. See [watch mode](#watch-mode). |
|
|
222
|
+
| `--variant <standard\|shop>` | The look of the page: Shopify's one-page checkout, or the Shop app's. A URL can ask for the other with `?variant=shop`. |
|
|
223
|
+
|
|
224
|
+
`app dev` takes the other options of [`serve`](cli.md#serve) (`--port`, `--host`, `--quiet`,
|
|
225
|
+
`--static`, `--customer`, ...).
|
|
226
|
+
|
|
227
|
+
### Watch mode
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
npx local-storefront app dev --app ../my-app --watch
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
It works with `serve --app` too. Three things happen:
|
|
234
|
+
|
|
235
|
+
- **A save builds the Function again**: its build command, or lsf's build of a JavaScript
|
|
236
|
+
Function. The terminal says `↻ building discount-codes…`, then
|
|
237
|
+
`✓ discount-codes built in 1.4s`.
|
|
238
|
+
- **A build that fails keeps the last module**, as `shopify app dev` does: the checkout runs it.
|
|
239
|
+
The terminal, a banner of the checkout page and the home page show the error; the next build
|
|
240
|
+
that works clears it.
|
|
241
|
+
- **The open pages reload** once the builds a change started are over: the checkout, the
|
|
242
|
+
thank-you page and the home page. What the buyer entered is kept, it is in the session.
|
|
243
|
+
|
|
244
|
+
A change to a checkout UI extension's bundle, an extension added or removed,
|
|
245
|
+
`shopify.app.toml`, a scenario or the store data reloads the pages without a build.
|
|
246
|
+
`lsf app function run` has no watch mode: it runs the modules in `dist/`, as they are.
|
|
247
|
+
|
|
248
|
+
#### Details
|
|
249
|
+
|
|
250
|
+
- **At the start**, the modules that are there are used, as without `--watch`, when nothing
|
|
251
|
+
changed since the last watch of the app. For each Function that has a build command
|
|
252
|
+
(`[extensions.build] command`), the user's cache folder (`~/Library/Caches/lsf/watch/` on
|
|
253
|
+
macOS, `~/.cache/lsf/watch/` on Linux, never the app) records what its last build was made
|
|
254
|
+
from (its files, the files the builds share, its command) and a hash of the module it
|
|
255
|
+
published. A module is trusted only when both still match, and no build of it runs (see the
|
|
256
|
+
write lock below). Otherwise it is built: a Function with no module a run can load, one whose
|
|
257
|
+
files changed, and one whose module changed since, whether a build was stopped halfway,
|
|
258
|
+
failed after writing it, or another process wrote it (a build left running by a server that
|
|
259
|
+
was killed, another server of the same app). A Function with nothing recorded, on the first
|
|
260
|
+
watch of an app, keeps the module it has, and the terminal says `using the existing builds;
|
|
261
|
+
--build rebuilds them all at start`. `--watch --build` builds every Function that has a build
|
|
262
|
+
command. A build that fails does not stop the server.
|
|
263
|
+
- **One build at a time writes a Function's module**, across the servers of an app: each
|
|
264
|
+
Function has a write lock, next to the record (`locks/<handle>.lock`). A build takes it before
|
|
265
|
+
it starts, and the server lets go of it once it read, checked, published and recorded the
|
|
266
|
+
module. Another build of that Function waits meanwhile, and the terminal says
|
|
267
|
+
`↻ discount-codes: waiting for another build of it to end (another lsf, or one left running)`;
|
|
268
|
+
Ctrl-C still stops it at once. On macOS and Linux the build holds the lock too, and so does
|
|
269
|
+
what it starts (`cargo`, `rustc`): a build left running by a server that was killed holds it
|
|
270
|
+
until it is over, so what it writes is never taken for the module of another build. A lock
|
|
271
|
+
that only a process the build started still holds, once the build is over (a compiler cache's
|
|
272
|
+
server, such as sccache), is not waited for. Limits: a build that closes the descriptors it
|
|
273
|
+
inherits, or starts its writer apart from itself, does not hold the lock to its end; a
|
|
274
|
+
process that stays in a killed server's build's process group is waited for while it runs. On
|
|
275
|
+
Windows only the server holds the lock: a build that a killed server left running is not
|
|
276
|
+
waited for there (the next start still builds again a module that is not the one recorded).
|
|
277
|
+
- **What is watched**: the folder of each extension (`src/`, the input queries, its
|
|
278
|
+
`shopify.extension.toml`, `locales/`, and the `dist/` bundle of a checkout UI extension), what
|
|
279
|
+
the builds of the Functions read in the app's own folder (a Cargo workspace's `Cargo.toml` and
|
|
280
|
+
`Cargo.lock`, `rust-toolchain.toml`, `package.json` and its lock file, `pnpm-workspace.yaml`,
|
|
281
|
+
`tsconfig.json`, and the folders that hold a crate or a package the Functions may share: a
|
|
282
|
+
`Cargo.toml` or a `package.json` at most three levels down, as `crates/common/Cargo.toml`),
|
|
283
|
+
`shopify.app.toml`, and the extension folders that are added or removed. A symbolic link is
|
|
284
|
+
watched where it leads. The store data and its scenarios are watched as always.
|
|
285
|
+
- **What is not**: the rest of the app's own folder, which is the app's and its dev server's: its
|
|
286
|
+
sources (`src/`, `app/`), its pages (`index.html`), and what its tools build or fetch at its
|
|
287
|
+
root (`dist/` of trunk or Vite, `build/`, `target/`, `node_modules/`), so that an app whose
|
|
288
|
+
web front end rebuilds on every save (an openshop project) does not build its Functions and
|
|
289
|
+
reload the pages each time. Nor `.git/`, `.shopify/` and other dot folders, editors' temporary
|
|
290
|
+
files, Markdown documents (in any folder), the store data folder (`shopify-local/`, or the one
|
|
291
|
+
`--data` names), and what a build writes: the `dist/` folder of a Function that has a build
|
|
292
|
+
command, its module (`[extensions.build] path`), `schema.graphql`, and the types of a
|
|
293
|
+
JavaScript Function's typegen (`generated/api.ts`). A build does not start itself again.
|
|
294
|
+
- **A change to a Function** with a build command runs that command in its folder, as `--build`
|
|
295
|
+
does. A save that writes several files builds once. One build at a time per Function: a change
|
|
296
|
+
during its build makes one more build after it. Up to 3 Functions build at once (the Functions
|
|
297
|
+
of one Cargo workspace wait for each other on its lock). A JavaScript Function without a build
|
|
298
|
+
command counts as one with a build command: lsf builds it (`lsf app function build`, in a
|
|
299
|
+
process of its own).
|
|
300
|
+
- **A change to the other files of the app** builds every Function that has a build command:
|
|
301
|
+
`↻ Cargo.toml changed: building 12 Functions`.
|
|
302
|
+
- **The pages reload** once per change, however many Functions it builds. The home page runs the
|
|
303
|
+
Functions of the scenarios again.
|
|
304
|
+
- **The checkout never runs what a build is writing.** It runs a copy of the last module of each
|
|
305
|
+
Function that built, kept out of the app (in the system's temporary folder, removed when the
|
|
306
|
+
server stops). Once a build is over and its module compiles as a run compiles it (and exports
|
|
307
|
+
a function), the copy is replaced in one step. The checkout, the Functions dialog and the runs
|
|
308
|
+
of the home page all read it. `dist/` is only written by the build command.
|
|
309
|
+
- **A build that fails**, or writes a module that does not compile (`the build wrote an invalid
|
|
310
|
+
module: …`), leaves the last copy. The terminal prints
|
|
311
|
+
`✗ discount-codes: build failed (exit status: 101)`, the first error (from its `error` line to
|
|
312
|
+
the end of its source context) and the last lines of the output; the checkout page shows them
|
|
313
|
+
in a banner and in the overview of the Functions dialog, and the home page lists the build
|
|
314
|
+
first among its problems. A Function that never built is not built until a build works, and
|
|
315
|
+
the banner and the home page say `no module of it built yet`.
|
|
316
|
+
- **A build that hangs** does not hold the server: it is stopped after 10 minutes and fails.
|
|
317
|
+
Ctrl-C stops the builds that run, and so do `SIGTERM` and `SIGHUP` on macOS and Linux. One
|
|
318
|
+
stopped halfway is built again at the next start, unless what it left (its files and the bytes
|
|
319
|
+
of its module) is exactly what was last published and recorded. On Windows, only Ctrl-C
|
|
320
|
+
stops the builds.
|
|
321
|
+
|
|
322
|
+
The files are watched through the system's events (FSEvents on macOS, inotify on Linux, folder
|
|
323
|
+
by folder, without the tools' folders): at rest, watching an app of 90 extensions costs about
|
|
324
|
+
0.2 % of a core. Where a file system has no events, they are polled.
|
|
325
|
+
|
|
326
|
+
## The look of the page
|
|
327
|
+
|
|
328
|
+
The standard look follows Shopify's one-page checkout, in lsf's own CSS: a header with the name
|
|
329
|
+
of the shop and a cart icon over a rule that runs the page's width; on a screen of 1000px or more
|
|
330
|
+
two columns (the form on white, 500px wide, and the order summary on a grey panel as tall as the
|
|
331
|
+
page, 400px wide, that stays in view while the form scrolls); below that one column with the
|
|
332
|
+
summary folded into a bar under the header that shows the total. Above the contact section, the
|
|
333
|
+
express buttons: the payment methods of the `ACCELERATED_CHECKOUT` placement that the payment
|
|
334
|
+
customizations left (Shop Pay, PayPal, Apple Pay, Google Pay… in their colors), then "OR". A click
|
|
335
|
+
says that lsf does not simulate the wallet. Fields have their label inside
|
|
336
|
+
the box, which floats to the top once there is something to label; payment and shipping methods
|
|
337
|
+
are one bordered box of rows, the chosen row tinted with its accent border; the order summary
|
|
338
|
+
has 64px thumbnails with the quantity hanging off a corner, the discount field with its Apply
|
|
339
|
+
button inside, and the Total row.
|
|
340
|
+
|
|
341
|
+
Everything that sets the look is a CSS custom property in `crates/app/assets/tokens.css`,
|
|
342
|
+
read by the page and by the frame of the extensions, so that an extension looks like the page
|
|
343
|
+
around it. The defaults are those of a store with no branding: system fonts and a 14px base text
|
|
344
|
+
(`--lsf-font`, `--lsf-font-size-base`, `--lsf-font-step`), black on white and a blue accent
|
|
345
|
+
(`--lsf-color-accent`, `--lsf-color-text`, `--lsf-color-bg`, `--lsf-color-border`, ...), a grey
|
|
346
|
+
order summary (`--lsf-summary-*`), corner radii of 8, 12 and 16px (`--lsf-radius-small`, `-base`,
|
|
347
|
+
`-large`, or one `--lsf-radius-global` for all), spacing (`--lsf-space-*`) and control sizes. The
|
|
348
|
+
hover color, the three light tints, the focus ring and the subdued text on a tint are derived
|
|
349
|
+
from the accent, so one color brands the page. The Shop skin sets its own accent, page color and
|
|
350
|
+
radii in the same file.
|
|
351
|
+
|
|
352
|
+
## The home page
|
|
353
|
+
|
|
354
|
+
`GET /` of `lsf app dev` (`/__lsf/checkouts` with a theme; `?format=json` answers what the
|
|
355
|
+
page says, for tools) has a bar at the top and six tabs. It has the look of the checkout and
|
|
356
|
+
needs no network: it sends no request of the Functions, only the checkout UI extensions loaded to
|
|
357
|
+
check them can call a server (see below).
|
|
358
|
+
|
|
359
|
+
- **The bar**: the app and its store data, how many problems there are (a link to the Problems
|
|
360
|
+
tab), "More" (a checkout with [every extension target](#every-extension-target), the Shop
|
|
361
|
+
look), "Admin" (the app in lsf's admin, when the Admin API runs:
|
|
362
|
+
[preview the embedded app](admin.md#preview-the-embedded-app)), the
|
|
363
|
+
[customer account](#customer-account) and "Open the checkout".
|
|
364
|
+
- **Scenarios**: the groups on the left, the scenarios on the right (see below).
|
|
365
|
+
- **Functions**, **Checkout UI extensions**, **Customer account UI extensions**: a tab for
|
|
366
|
+
each kind of extension of the app.
|
|
367
|
+
- **Customers**: a card for each customer of the store data, with a checkout logged in as them
|
|
368
|
+
and their customer account.
|
|
369
|
+
- **Problems**, errors first, each on one line. The first eight of each kind show, a "Show N
|
|
370
|
+
more" opens the others. The link on the right of a problem opens a fresh checkout where it
|
|
371
|
+
shows, as the Open button of a scenario does, and its name is under the link. Its details also
|
|
372
|
+
link to the rows of this page it is about: a link opens the tab of what it leads to.
|
|
373
|
+
- A Function that fails: "Open the run", the first scenario it fails in, with the [Functions
|
|
374
|
+
dialog](#what-runs) open on that run (`?functions=<handle>`, and `:<target>` when the
|
|
375
|
+
Function has several). The details link to the run in each of the other scenarios.
|
|
376
|
+
- Another problem of a Function: the scenario that runs it (of those that name it in
|
|
377
|
+
`extensions`, the one with the fewest; otherwise the first that runs every extension), with
|
|
378
|
+
the dialog on it, on the target the message names. A Function that has a run there (a legacy target, a missing build, whose run
|
|
379
|
+
fails) opens on that run, "Open the run"; one that never runs (an `api_version` that is not
|
|
380
|
+
supported, an unknown or listed-only target) opens on the Overview, which says it did not
|
|
381
|
+
run, "Open in checkout".
|
|
382
|
+
- A checkout UI extension: the scenario that runs it, chosen the same way, without the dialog, where it shows
|
|
383
|
+
on the page, with what goes wrong. One the page does not load (not built, an API version that
|
|
384
|
+
is not supported, an `owner_type` that is wrong or missing) opens the Overview of the dialog on
|
|
385
|
+
it.
|
|
386
|
+
- An extension that no scenario runs: the checkout of the store's cart, `/checkout`, the same
|
|
387
|
+
way.
|
|
388
|
+
- A scenario that does not validate cannot open: the link goes to its row.
|
|
389
|
+
- Errors: an extension that cannot load or run (no build, an API version that is not
|
|
390
|
+
supported or missing, an unknown Function target, a missing or wrong `owner_type`, what the
|
|
391
|
+
loader refuses), a scenario that does not validate or names an extension the app lacks, an
|
|
392
|
+
error in the store data, and **a Function that fails when a scenario runs**.
|
|
393
|
+
- Warnings: legacy targets, targets the local checkout knows of and does not run, targets the
|
|
394
|
+
page has no place for, and what else the loader says that does not stop anything.
|
|
395
|
+
- **The scenarios** ([scenarios](#scenarios)) are in groups: the part of their titles before
|
|
396
|
+
`: ` ("Discount: buy 2, get 1" is in "Discount") when two scenarios or more share it, the others
|
|
397
|
+
first under "Checkouts". The menu on the left shows them all or one group, with the worst of
|
|
398
|
+
their dots and how many they hold; a row in its group drops what the group says of its title.
|
|
399
|
+
In a group, those with problems come first, then by name. A row has a status dot: the worst
|
|
400
|
+
of its own problems (it does not validate, it names an extension the app does not have) and of the Functions that fail in it, which are errors, and of the problems of the
|
|
401
|
+
extensions it runs, each at its own level (a Function that is not built makes it an error, a
|
|
402
|
+
legacy target a warning). Then the title, its description (else a few words for its cart,
|
|
403
|
+
buyer, country, language and codes), and Open, a fresh checkout for it. Its details say what it
|
|
404
|
+
runs ("every extension of the app" without an `extensions` list), its name and file, the cart,
|
|
405
|
+
the buyer, what was entered, the settings it overrides, the extensions it runs, and a link that
|
|
406
|
+
opens it with every target. The discounts are listed only when the scenario sets its own
|
|
407
|
+
(`checkout.discounts`); otherwise they are "the store's N discounts". A search box (it looks in
|
|
408
|
+
every group) and a filter (all, with problems, Functions, UI) narrow the list. A link to a row
|
|
409
|
+
(`#scenario-…`, from a problem) shows every group and opens the row.
|
|
410
|
+
- **The customers** ([login profiles](#log-in-as-a-customer)): name, email, company, tags,
|
|
411
|
+
orders, saved addresses and country, "Checkout as …", which opens the checkout logged in as
|
|
412
|
+
them (the first scenario's, for its cart, when there is one), and "Account", their customer
|
|
413
|
+
account.
|
|
414
|
+
- **The extensions**: the Functions, the checkout UI extensions and the customer account UI
|
|
415
|
+
extensions, each in its tab. An extension with targets in both the checkout and the customer
|
|
416
|
+
account is in both. A problem of an extension that only renders in
|
|
417
|
+
the customer account opens it ("Open the customer account"). One line each: its status, handle and
|
|
418
|
+
name, Function API or capabilities, API version, how many scenarios run it, its targets, and its
|
|
419
|
+
first problem. Its details list each target (the Function API, a `.fetch` target that reaches
|
|
420
|
+
the network, a legacy target; for a UI extension, the place it has on the page, such as
|
|
421
|
+
"Checkout summary: under each line"), how to build it, and all of its problems.
|
|
422
|
+
|
|
423
|
+
An extension that `--extension` leaves out is listed and marked so. The page follows the locale
|
|
424
|
+
prefix of its URL (`/fr/` links to `/fr/checkout`; `/__lsf/checkouts?locale=fr` does the same).
|
|
425
|
+
|
|
426
|
+
### The Functions that fail
|
|
427
|
+
|
|
428
|
+
The home page runs the Functions of every scenario that opens, as its checkout would when it
|
|
429
|
+
opens (what `lsf app function run --scenario` does), and lists each Function target that fails, with
|
|
430
|
+
the error and the scenarios it fails in: a trap or a panic, more than 11 million instructions,
|
|
431
|
+
an output that does not fit the schema of its API version, a `.fetch` request out of Shopify's
|
|
432
|
+
limits. A target that fails in several ways is listed once per error, each with its own
|
|
433
|
+
scenarios and logs. The scenario and the Function get an error status.
|
|
434
|
+
|
|
435
|
+
lsf stops a run at 110 million instructions, ten times Shopify's limit: an endless loop fails in
|
|
436
|
+
a moment (`exceeded Shopify's limit of 11,000,000 instructions: stopped at 110,000,000`), and a
|
|
437
|
+
run under that has its exact count.
|
|
438
|
+
|
|
439
|
+
This check sends no HTTP request: opening the home page never calls a server. A `.fetch` target
|
|
440
|
+
runs, and the request it asks for is checked against [Shopify's limits](#network-access) (a
|
|
441
|
+
request out of them fails the target), but it is not sent: the `.run` target reads what it reads
|
|
442
|
+
when the connection fails, `502 Bad Gateway (Connection Failed)`. A Function that only fails on
|
|
443
|
+
some answer of its server is found by opening its scenario, which sends the request.
|
|
444
|
+
|
|
445
|
+
Running them all takes a moment (under a second for the ~90 scenarios of the
|
|
446
|
+
[playground](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground)),
|
|
447
|
+
so the page does not wait for it: it shows "Running the Functions…" and asks
|
|
448
|
+
`GET /__lsf/checkouts/check`, which answers what it found. The check has 20 s: it looks at the
|
|
449
|
+
time before each Function runs (each run is bounded on its own by the instruction limit), and
|
|
450
|
+
once they are spent it stops and says so ("Checked 40 of 86
|
|
451
|
+
scenarios"). A complete check is kept until the store data or the app (a Function, its Wasm
|
|
452
|
+
module) changes: the page then has it at once, and the next check runs on the next request after
|
|
453
|
+
a change. A partial check, or one whose app changed while it ran (a module rebuilt), is not kept.
|
|
454
|
+
A check that fails or takes more than 30 s says so, and the rest of the page stays. Without
|
|
455
|
+
JavaScript, the page links to `?check=1`, which runs the check before it answers.
|
|
456
|
+
|
|
457
|
+
### The checkout UI extensions that do not load
|
|
458
|
+
|
|
459
|
+
The page also loads the bundle of each checkout UI extension in a hidden frame of the sandbox,
|
|
460
|
+
as the checkout does, without rendering it. Unlike the check of the Functions, this loads the
|
|
461
|
+
real bundles: a bundle that calls the network as it loads does so. A bundle that does not load
|
|
462
|
+
(no file), throws while it loads, or does not register a target it declares (`shopify.extend`),
|
|
463
|
+
is listed with the problems with what it threw, and the scenarios that run it take the level of
|
|
464
|
+
that problem, as for any extension with a problem. A frame that says nothing within 5 s is a
|
|
465
|
+
warning that the page could not tell; one that reported an error and never finished is that
|
|
466
|
+
error. An extension that throws only when it renders or when the buyer acts is not found this
|
|
467
|
+
way.
|
|
468
|
+
|
|
469
|
+
Messages show what is between backticks as code, and the paths inside the app relative to its
|
|
470
|
+
folder (the full path is in the tooltip).
|
|
471
|
+
|
|
472
|
+
## Scenarios
|
|
473
|
+
|
|
474
|
+
A scenario is a named checkout setup: one file of `checkouts/` in the
|
|
475
|
+
[store data](data-format.md#checkout-scenarios) (`<data>/checkouts/<name>.json`, the data folder
|
|
476
|
+
being `--data`, `LSF_DATA` or `shopify-local/`). It sets what a checkout depends on, on top of
|
|
477
|
+
the store data: the cart (lines, quantities, selling plans, note, attributes), the buyer (a guest,
|
|
478
|
+
a customer, a company location), the country, the language, what the buyer already entered
|
|
479
|
+
(email, address, discount codes, delivery and payment), overrides of the `checkout` section
|
|
480
|
+
(shipping rates, payment methods, discounts, Function settings, tax), the look and the
|
|
481
|
+
extensions that run, and what the checkout must look like once the Functions ran
|
|
482
|
+
([`expect`](#expected-results), which `app check` checks). The files are reloaded when they
|
|
483
|
+
change, and `validate` checks them: run it in the app folder, no theme needed
|
|
484
|
+
(`npx local-storefront validate`).
|
|
485
|
+
|
|
486
|
+
```json
|
|
487
|
+
{
|
|
488
|
+
"title": "Three tees in French",
|
|
489
|
+
"session": { "customer": "jane.doe@example.com", "cart": { "items": [{ "variant": "TEE-BLK-M", "quantity": 3 }] } },
|
|
490
|
+
"entered": { "discount_codes": ["WELCOME"] },
|
|
491
|
+
"language": "fr",
|
|
492
|
+
"checkout": { "tax_rate": 20 },
|
|
493
|
+
"variant": "shop"
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
- `GET /checkout?scenario=<name>` opens it: a **new session** (a new cookie, a new checkout token,
|
|
498
|
+
nothing kept from the checkout before, such as what the extensions stored) with the cart,
|
|
499
|
+
buyer and entries of the scenario, then a redirect to its checkout, under the locale root of its
|
|
500
|
+
language. The browser keeps that session, so the theme (with `serve --app`) shows its cart and
|
|
501
|
+
customer too. A scenario that does not exist answers `404`, and one with errors `422`, with a
|
|
502
|
+
page that says what is wrong.
|
|
503
|
+
- The checkout keeps the settings and the extensions of its scenario for the whole session, an
|
|
504
|
+
order included, and follows the file when it changes. The state has `scenario`
|
|
505
|
+
(`{name, title}`) and the page names it in its header.
|
|
506
|
+
- `lsf app function run --scenario <name>` runs the Functions for a scenario.
|
|
507
|
+
- Only one scenario is open per browser: the session is the cookie's.
|
|
508
|
+
|
|
509
|
+
`npx local-storefront app init --app ../my-app` writes the scenarios, made for the app:
|
|
510
|
+
|
|
511
|
+
| The app has | Scenarios |
|
|
512
|
+
|---|---|
|
|
513
|
+
| a cart transform | `cart-transform`: several lines of more than one unit and one of three, for a merge, an expand or an update |
|
|
514
|
+
| a discount Function | `discount-automatic` (a cart of six, three and two units) and `discount-code` (the same cart with `SAVE10` entered; the code of a discount of the store data is kept) |
|
|
515
|
+
| a delivery customization | `delivery-options`: standard, express, local delivery and store pickup |
|
|
516
|
+
| a payment customization | `payment-methods`: card, PayPal, cash on delivery and bank deposit in the list; PayPal, Shop Pay and Apple Pay as express buttons (PayPal in both placements, as with PayPal Express on) |
|
|
517
|
+
| a validation Function | `validation`: a big cart, for the Function to refuse |
|
|
518
|
+
| a checkout UI extension | `all-extensions`: a customer, several lines (one with a subscription), shipping options, and with a discount Function, a discount of it with the code `SAVE10` entered |
|
|
519
|
+
| (always) | `guest`, `customer` (with a note and an attribute), `b2b` (a company customer at another location), `abroad` (another country and language), `subscription`, `pre-order` (a deposit, the rest later), `shop-look`, `large-cart` |
|
|
520
|
+
|
|
521
|
+
A scenario about one kind of Function runs that Function and the checkout UI extensions, and no
|
|
522
|
+
other Function (`extensions`), so that its effect shows alone; `all-extensions` names every
|
|
523
|
+
extension. A scenario that names a Function runs it whether or not the shop has an owner of it
|
|
524
|
+
(see [what runs](#owners-and-scenarios)): it tries the Function alone. The others
|
|
525
|
+
(`guest`, `b2b`, `abroad`, `large-cart`, …) name none: they show the shop's checkout, where only
|
|
526
|
+
the Functions with an enabled owner run. A Function you add later is not in those lists: add its
|
|
527
|
+
handle to them. The settings of your Functions are the store data's: the scenarios leave
|
|
528
|
+
`checkout.functions` alone, and the `discount-*` scenarios list the discount Functions again
|
|
529
|
+
with the settings (and the code) `checkout.discounts` gives them. The scenarios are a place to
|
|
530
|
+
start: edit them.
|
|
531
|
+
|
|
532
|
+
**A discount Function runs in `discount-automatic`, `discount-code` and `all-extensions` only.** It runs only for a
|
|
533
|
+
discount that names it ([set up the checkout](#set-up-the-checkout)), and the other scenarios
|
|
534
|
+
(`b2b`, `abroad`, `large-cart`, …) have none: `app check` passes on them without running it. It
|
|
535
|
+
warns only about a Function no scenario ran at all (`warning <handle>: ran in no scenario, so
|
|
536
|
+
nothing checks it`, `1 Function ran in no scenario` in the summary, and `ranInNoScenario` in
|
|
537
|
+
JSON), and its exit code stays the same. To run it in
|
|
538
|
+
every scenario, a B2B buyer, another country and a large cart included, give it a discount in the
|
|
539
|
+
`checkout` section of the store data (`store.json`, or the file that has the section):
|
|
540
|
+
|
|
541
|
+
```json
|
|
542
|
+
{
|
|
543
|
+
"checkout": {
|
|
544
|
+
"discounts": [
|
|
545
|
+
{
|
|
546
|
+
"function": "my-discount",
|
|
547
|
+
"title": "My discount",
|
|
548
|
+
"metafields": { "$app.function-configuration": { "type": "json", "value": { "percentage": 10 } } }
|
|
549
|
+
}
|
|
550
|
+
]
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
The `discount-*` scenarios keep their own `checkout.discounts`.
|
|
556
|
+
|
|
557
|
+
`app init` creates the data folder with the demo store when it does not exist (and a
|
|
558
|
+
selling plan with a deposit for `pre-order`). When it exists, it uses the products, customers,
|
|
559
|
+
countries, languages and selling plans it has, and says which scenarios it skipped and why. It
|
|
560
|
+
never overwrites a file without `--force`, and refreshes `schema/` so that editors complete the
|
|
561
|
+
scenarios (`"$schema": "../schema/checkout-scenario.schema.json"`).
|
|
562
|
+
|
|
563
|
+
A Function that reads its settings as the variables of its input query
|
|
564
|
+
(`[extensions.input.variables]`) cannot run without them. `app init` gives each one the
|
|
565
|
+
settings the store data lacks: those of the app's own `shopify-local/` when it has them, or empty
|
|
566
|
+
values of the type of each variable (`[]`, `""`, `0`, `false`) to fill in. Those of a discount
|
|
567
|
+
Function go in the `metafields` of its discounts in the scenarios; the others in
|
|
568
|
+
`checkout.functions` of `app-settings.json`. When another data file has the `checkout` section,
|
|
569
|
+
it prints what to add to it instead.
|
|
570
|
+
|
|
571
|
+
### Expected results
|
|
572
|
+
|
|
573
|
+
`expect` says what the checkout of a scenario must look like once its Functions ran, and
|
|
574
|
+
`npx local-storefront app check` fails when it does not. An app that hides PayPal from the express
|
|
575
|
+
buttons, and only from them, asserts that PayPal is gone from the buttons and still in the list:
|
|
576
|
+
|
|
577
|
+
```json
|
|
578
|
+
{
|
|
579
|
+
"title": "PayPal Express hidden, PayPal kept",
|
|
580
|
+
"session": { "cart": { "items": [{ "variant": "TEE-BLK-M", "quantity": 3 }] } },
|
|
581
|
+
"checkout": {
|
|
582
|
+
"payment_methods": [
|
|
583
|
+
{ "handle": "card", "name": "Credit card" },
|
|
584
|
+
{ "handle": "paypal", "name": "PayPal", "placements": ["PAYMENT_METHOD", "ACCELERATED_CHECKOUT"] },
|
|
585
|
+
{ "handle": "shop_pay", "name": "Shop Pay", "placements": ["ACCELERATED_CHECKOUT"] }
|
|
586
|
+
]
|
|
587
|
+
},
|
|
588
|
+
"extensions": ["payment-rules"],
|
|
589
|
+
"expect": {
|
|
590
|
+
"express_methods": { "excludes": ["paypal"], "equals": ["shop_pay"] },
|
|
591
|
+
"payment_methods": { "includes": ["paypal"] },
|
|
592
|
+
"errors": { "equals": [] },
|
|
593
|
+
"totals": { "total": "96.00" }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Each list takes `includes` (each of these matches an entry of the list, in any order),
|
|
599
|
+
`excludes` (none of these matches one) and `equals` (the whole list, in order; `[]` is an empty
|
|
600
|
+
list, so `"errors": { "equals": [] }` says there is no error):
|
|
601
|
+
|
|
602
|
+
| List | What it is | A string matches | An object can give |
|
|
603
|
+
|---|---|---|---|
|
|
604
|
+
| `payment_methods` | The payment methods of the list (`PAYMENT_METHOD`), in order, once the payment customizations ran | the handle or the name | `handle`, `name` |
|
|
605
|
+
| `express_methods` | The express buttons (`ACCELERATED_CHECKOUT`), in order | the handle or the name | `handle`, `name` |
|
|
606
|
+
| `delivery_options` | The delivery options, in the order the buyer sees them, once the delivery customizations and the shipping discounts ran | the handle or the title | `handle`, `title`, `cost` |
|
|
607
|
+
| `discounts` | The discounts applied: product, order and shipping ones | the title or the code | `title`, `code`, `class` (`PRODUCT`, `ORDER`, `SHIPPING`), `amount` |
|
|
608
|
+
| `lines` | The lines, once the cart transforms ran | the SKU or the title | `sku`, `title`, `quantity`, `price` (of one unit), `total` (after its discounts), `components`, `attributes` |
|
|
609
|
+
| `errors` | The errors that block the order: those validation Functions report, and those of Functions that failed | the message | `message`, `target` |
|
|
610
|
+
|
|
611
|
+
A string names an entry, so a renamed method is matched by its new name (`"Card (secure)"`) or by
|
|
612
|
+
its handle. An object gives the fields the entry must have, and leaves the others free:
|
|
613
|
+
`{ "handle": "standard", "cost": "0.00" }` is the option `standard`, free, whatever its title.
|
|
614
|
+
Amounts are written as in the store data: cents (`490`) or a decimal string (`"4.90"`). A line's
|
|
615
|
+
`components` are the lines a cart transform merged into it or split it into, in order, each by
|
|
616
|
+
its SKU or title (`[]`: none); its `attributes`, those it must have, by key. `totals` takes
|
|
617
|
+
amounts, each checked exactly: `subtotal`, `discounts` (product and order ones), `shipping`
|
|
618
|
+
(after the shipping discounts), `tax`, `total`, `due_now` and `due_later`.
|
|
619
|
+
|
|
620
|
+
The Functions run for `expect` as the order is placed (`buyerJourney.step` is
|
|
621
|
+
`CHECKOUT_COMPLETION`), as `app function run` runs them. `"step": "interaction"` checks the page
|
|
622
|
+
as the buyer sees it before: a validation Function that waits for "Pay now" reports nothing then.
|
|
623
|
+
Only a validation Function tells the two apart.
|
|
624
|
+
|
|
625
|
+
`app check` prints each expectation that is not met: the scenario and the assertion, what the
|
|
626
|
+
scenario expects, and what the checkout has. With a Function that hides nothing:
|
|
627
|
+
|
|
628
|
+
```text
|
|
629
|
+
expect express-buttons: express_methods.excludes
|
|
630
|
+
expected: no entry matching "paypal"
|
|
631
|
+
actual: [{"handle": "paypal", "name": "PayPal"}, {"handle": "shop_pay", "name": "Shop Pay"}]
|
|
632
|
+
expect express-buttons: express_methods.equals
|
|
633
|
+
expected: ["shop_pay"]
|
|
634
|
+
actual: [{"handle": "paypal", "name": "PayPal"}, {"handle": "shop_pay", "name": "Shop Pay"}]
|
|
635
|
+
checked 1 of 1 scenario in 0.0 s: 0 failures, 0 invalid scenarios, 2 of 5 expectations unmet
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
`actual` shows each entry with the fields the expectation names, amounts as decimal strings, so
|
|
639
|
+
that it can be pasted into the scenario. Each entry of `includes` and `excludes`, each `equals`
|
|
640
|
+
and each amount is one expectation: when all are met, the summary says `5 expectations met`.
|
|
641
|
+
`--format json` has them in `expectations` ([`app check`](cli.md#app-check)), where `expected`
|
|
642
|
+
is the entry, the list or the amount as the scenario writes it:
|
|
643
|
+
|
|
644
|
+
```json
|
|
645
|
+
"expectations": {
|
|
646
|
+
"checked": 5,
|
|
647
|
+
"unchecked": [],
|
|
648
|
+
"unmet": [
|
|
649
|
+
{
|
|
650
|
+
"scenario": "express-buttons",
|
|
651
|
+
"file": "checkouts/express-buttons.json",
|
|
652
|
+
"step": "completion",
|
|
653
|
+
"assertion": "express_methods.excludes",
|
|
654
|
+
"expected": "paypal",
|
|
655
|
+
"actual": [{ "handle": "paypal", "name": "PayPal" }, { "handle": "shop_pay", "name": "Shop Pay" }]
|
|
656
|
+
}
|
|
657
|
+
]
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
A scenario without `expect` is checked as before: its Functions must run without an error. With
|
|
662
|
+
`--extension`, the `expect` of a scenario that runs a Function left out is not checked (its
|
|
663
|
+
checkout is another one). The scenario still runs, its expectations are not counted in
|
|
664
|
+
`checked`, `expectations.unchecked` names it, and the exit code does not change. The summary
|
|
665
|
+
says so: ``the `expect` of 1 scenario not checked (--extension leaves out a Function it runs)``.
|
|
666
|
+
|
|
667
|
+
A name in `excludes` that the checkout does not offer, such as `"paypall"`, is never in the list,
|
|
668
|
+
so the expectation would always pass: `validate` warns about it (`expect_checks_nothing`), with
|
|
669
|
+
the closest handle. The scenario still opens.
|
|
670
|
+
|
|
671
|
+
## Log in as a customer
|
|
672
|
+
|
|
673
|
+
The customers of the [store data](data-format.md#customers-and-sessions) are the login profiles
|
|
674
|
+
of the checkout. "Log in", at the right of the Contact heading, opens a menu of them: name,
|
|
675
|
+
email, and what tells them apart (B2B company and its locations, tags, orders, saved addresses,
|
|
676
|
+
country). Pick one and the checkout is logged in as them; "Continue as a guest" logs it out. The
|
|
677
|
+
menu is a menu button: the arrows, Home and End move between the customers, Escape closes it and
|
|
678
|
+
gives the focus back to the button, Tab and a click elsewhere close it.
|
|
679
|
+
|
|
680
|
+
Logged in, the checkout does what Shopify's does for a customer who has an account:
|
|
681
|
+
|
|
682
|
+
- **Contact** shows the account (name and email) instead of the email field, with "Switch" (the
|
|
683
|
+
same menu) and "Log out". The email and the phone are the customer's. A contact email that
|
|
684
|
+
is not the account's (a scenario's `entered.email`, or one typed later) is the one the
|
|
685
|
+
Functions, the extensions and the order get, so Contact shows it under the account, as "Email
|
|
686
|
+
for this order", where it can be corrected, with "Use <account email>" to go back to the
|
|
687
|
+
account's.
|
|
688
|
+
- **Delivery** starts with the customer's default address (the first of their `addresses`) and
|
|
689
|
+
offers the others in a "Saved addresses" select, with "Use a new address" for one they type.
|
|
690
|
+
The fields below hold the chosen address and can be edited.
|
|
691
|
+
- **A B2B customer** shows their company and chooses the location they buy for, among the ones
|
|
692
|
+
they have access to; the order ships to the address of that location ("Ship to the location").
|
|
693
|
+
- Everything that reads the buyer follows: the Functions (`buyerIdentity.customer` with `id`,
|
|
694
|
+
`email`, `displayName`, `hasTags`, `hasAnyTag`, `metafield`, `numberOfOrders`, `amountSpent`;
|
|
695
|
+
`buyerIdentity.purchasingCompany`; `isAuthenticated`), the checkout UI extensions
|
|
696
|
+
(`shopify.buyerIdentity.customer`, `email`, `phone`, `purchasingCompany`, whose subscribers are
|
|
697
|
+
called; the `sub` of the session token), the shipping rates (an address means rates), the
|
|
698
|
+
discounts and the payment methods the Functions decide on.
|
|
699
|
+
|
|
700
|
+
A login changes the session, the way `session.customer` of the data, a scenario or
|
|
701
|
+
`PUT /__lsf/session` set it: the theme (with `serve --app`) shows the same customer. The cart,
|
|
702
|
+
the checkout token, the choices of delivery and payment, the codes and the scenario the checkout
|
|
703
|
+
opened from stay. What belonged to the buyer before goes: the email and the phone they typed,
|
|
704
|
+
and an address the customer before had saved (an address typed as a guest stays for a customer
|
|
705
|
+
who has none). The checkout follows a login made anywhere else too (`/__lsf/login`, the control
|
|
706
|
+
API): the next time it is read, it is the new customer's.
|
|
707
|
+
|
|
708
|
+
- `GET /checkout?customer=<email>` opens the checkout logged in as a customer (`default` for the
|
|
709
|
+
first one, `none` for a guest); `/checkout?scenario=<name>&customer=<email>` opens a scenario
|
|
710
|
+
as someone else. Someone the data does not have answers `404` with the list of customers.
|
|
711
|
+
- A login from a URL is the login of the menu: `?scenario=<name>&customer=<email>` gives the same
|
|
712
|
+
checkout as opening `?scenario=<name>` and picking the customer in the menu. The scenario is
|
|
713
|
+
set up first (its `entered` included), then the login applies what is written above: the
|
|
714
|
+
contact (email and phone) becomes the customer's, the address their default one, and the
|
|
715
|
+
scenario's address stays only for a customer without one. When `customer` is the one the
|
|
716
|
+
scenario already logs in as, nothing changes, and everything the scenario entered stays.
|
|
717
|
+
- The page sends the [operations](#operations) `customer` and `companyLocation`.
|
|
718
|
+
- The state has `account`: `profiles` (the customers to log in as), `addresses` (the saved
|
|
719
|
+
addresses of the customer logged in, the default one first, each with an `id` and the
|
|
720
|
+
`address`; for a B2B customer, the one of their location) and `locations` (the locations a B2B
|
|
721
|
+
customer can buy for, with `current`). Extensions do not see it.
|
|
722
|
+
|
|
723
|
+
The Shop skin has the same menu, account and select.
|
|
724
|
+
|
|
725
|
+
The demo store (`lsf init`, `lsf app init`, and the built-in data) has a profile of each
|
|
726
|
+
kind:
|
|
727
|
+
|
|
728
|
+
| Customer | What for |
|
|
729
|
+
|---|---|
|
|
730
|
+
| `jane.doe@example.com` | A returning customer: two saved addresses, two orders, the tag `vip`, a `custom.loyalty_tier` metafield |
|
|
731
|
+
| `new.customer@example.com` | A new customer: no order, no address |
|
|
732
|
+
| `alex.morgan@example.com` | A B2B buyer of Northwind Hotels, with two locations (Portland, Seattle) |
|
|
733
|
+
| `camille.martin@example.com` | A customer in France: an address in Lyon, one order |
|
|
734
|
+
|
|
735
|
+
Not done: logging in with a password or a one-time code (any customer of the data, at once),
|
|
736
|
+
Shop Pay, store credit, the customer's language (the checkout is in the language of its URL),
|
|
737
|
+
and the B2B settings that depend on the location in Shopify (catalogs, price lists, payment
|
|
738
|
+
terms, deposits, and whether a buyer may ship to another address): a B2B buyer pays what the
|
|
739
|
+
store data says and may type an address.
|
|
740
|
+
|
|
741
|
+
### Where each target renders
|
|
742
|
+
|
|
743
|
+
Every target of Shopify's checkout UI extensions has a place (`assets/api.json`), besides those
|
|
744
|
+
of the [customer account](#customer-account):
|
|
745
|
+
|
|
746
|
+
- **Pickup**: the shipping rates of kind `pick_up` are a "Pick up" list of their own in each
|
|
747
|
+
delivery group, between `pickup-location-list.render-before` and `-after`, with
|
|
748
|
+
`pickup-location-option-item.render-after` under each. The pickup points come from Shopify's
|
|
749
|
+
pickup point Functions, which do not run here: with an extension at `pickup-point-list.render-*`,
|
|
750
|
+
the page shows the list with no point in it.
|
|
751
|
+
- **Payment apps**: under the selected method, `payment-option-item.details.render`, and under the
|
|
752
|
+
card fields of the credit card, `payment-option-item.hosted-fields.render-after`.
|
|
753
|
+
`payment-option-item.action-required.render` is a dialog over the page that opens when the buyer
|
|
754
|
+
selects a method ("Complete the payment form" opens it again). `shopify.target` is the payment
|
|
755
|
+
option; there are no payment method attributes.
|
|
756
|
+
- **Gift cards**: with an extension at `gift-card.render`, the payment step has "Pay with a gift
|
|
757
|
+
card"; ticking it shows the extension. `applyGiftCardChange` and `applyRedeemableChange` are
|
|
758
|
+
refused.
|
|
759
|
+
- **Bundles**: a line a cart transform expanded lists its products in the summary, or
|
|
760
|
+
`purchase.cart-line-item.line-components.render` renders in their place, on the checkout and the
|
|
761
|
+
thank-you page.
|
|
762
|
+
- **Chat**: `purchase.checkout.chat.render` and `purchase.thank-you.chat.render` sit over the page
|
|
763
|
+
in the bottom right corner.
|
|
764
|
+
- **Address autocomplete**: `purchase.address-autocomplete.suggest` renders nothing. As the buyer
|
|
765
|
+
types the address field, the page runs it (in a hidden frame) with `shopify.target` (`value`,
|
|
766
|
+
`field`, `selectedCountryCode`) and shows the suggestions it returns under the field. Picking one
|
|
767
|
+
fills the address from its `formattedAddress`, or from what
|
|
768
|
+
`purchase.address-autocomplete.format-suggestion` returns for it. A new request aborts the one
|
|
769
|
+
before (`shopify.signal`).
|
|
770
|
+
|
|
771
|
+
### Every extension target
|
|
772
|
+
|
|
773
|
+
`/checkout?targets=all` (or `&targets=all` after `?scenario=`) opens a checkout where each
|
|
774
|
+
extension renders at every target it declares: the steps of a cart that ships are shown whatever
|
|
775
|
+
the cart, and the thank-you page is shown under the checkout with the same cart (no order is
|
|
776
|
+
placed), so the thank-you targets are there too. A panel at the top lists every target of every
|
|
777
|
+
extension: shown, without a place on the page, waiting for something the cart lacks (shipping
|
|
778
|
+
methods need an address, a line target needs a line, a pickup target a rate of kind `pick_up`),
|
|
779
|
+
run as the buyer types (the address autocomplete), or in the customer account. The
|
|
780
|
+
`all-extensions` and `ui-every-target` scenarios are made for it.
|
|
781
|
+
|
|
782
|
+
## Customer account
|
|
783
|
+
|
|
784
|
+
With an app, `/account` is the customer account where its customer account UI extensions render:
|
|
785
|
+
the pages of Shopify's new customer accounts, filled with the customers and orders of the store
|
|
786
|
+
data. The home page links to it ("Customer account").
|
|
787
|
+
|
|
788
|
+
| Path | Page | Targets |
|
|
789
|
+
|---|---|---|
|
|
790
|
+
| `/account` | The orders of the customer logged in | `order-index.announcement.render`, `order-index.block.render`, `order.action.menu-item.render` on each order |
|
|
791
|
+
| `/account/orders/<id>` | The status of an order: its shipments, what is not shipped, its returns, the details, the payment and the summary | the 9 `order-status.*` targets, `order.action.menu-item.render` |
|
|
792
|
+
| `/account/profile` | Name, contact, addresses, payment methods; for a B2B customer, the company and each location it buys for (addresses, payment, staff) | the 8 `profile.*` targets |
|
|
793
|
+
| `/account/pages/<handle>/…` | The full page of an extension | `customer-account.page.render` |
|
|
794
|
+
| `/account/orders/<id>/pages/<handle>/…` | The full page of an extension for an order | `customer-account.order.page.render` |
|
|
795
|
+
|
|
796
|
+
`customer-account.footer.render-after` is at the bottom of every page. Pressing the button of an
|
|
797
|
+
extension's `order.action.menu-item.render` opens its `order.action.render` in a dialog, which
|
|
798
|
+
`shopify.close()` closes; a menu item with an `href` leads there instead.
|
|
799
|
+
|
|
800
|
+
- **Logged out**, the page lists the customers of the store data to log in as
|
|
801
|
+
([log in as a customer](#log-in-as-a-customer)); the avatar in the header switches customer or
|
|
802
|
+
logs out.
|
|
803
|
+
- **The orders** are those of the customer in the store data (`orders` of a customer), the most
|
|
804
|
+
recent first, and the last one the session placed in the local checkout, logged in as them.
|
|
805
|
+
The orders of a session are numbered after the last one of the store data (#1003 when its last
|
|
806
|
+
is #1002), one after the other, whatever the other sessions do, and placed at the frozen clock
|
|
807
|
+
of the data (`now`) if it has one: a test reads the same number and date every time. They live
|
|
808
|
+
as long as the session (`DELETE /__lsf/sessions` forgets every one). An order's shipments come
|
|
809
|
+
from its `fulfillment_status` (`fulfilled`: one shipment of every line; `partial`: its first
|
|
810
|
+
line), its returns from its `returns` ([data reference](data-reference.md#orderreturn)).
|
|
811
|
+
- **The `shopify` global** of a customer account target has what every target has (`i18n`,
|
|
812
|
+
`storage`, `sessionToken`, `settings`, `localization`, `extension`, `analytics`), and:
|
|
813
|
+
`authenticatedAccount` (`customer`, `purchasingCompany`); `navigation`; `toast.show`, which shows
|
|
814
|
+
a toast at the bottom of the page; `intents.invoke`, which answers `closed`. The order status
|
|
815
|
+
targets and the full page of an order have the Order Status API (`order`, `lines`, `cost`,
|
|
816
|
+
`shippingAddress`, `billingAddress`, `buyerIdentity`, `discountCodes`, `authenticationState`,
|
|
817
|
+
…) and `appMetafields`, with the order's own metafields (`owner_type = "ORDER"`, see
|
|
818
|
+
`shopify.appMetafields` below), the line, shipment, return, location and order targets their
|
|
819
|
+
`target`, `fulfillmentId`, `returnId`, `locationId` and `orderId`.
|
|
820
|
+
- **Navigation**: `shopify:customer-account/orders`, `…/orders/<id>` and `…/profile` lead to the
|
|
821
|
+
pages; `extension://<handle>` (or `extension:<handle>/<path>`) to the full page of another
|
|
822
|
+
extension; `extension://<route>` and `/<route>`, on a full page, to a route of its own. The page
|
|
823
|
+
loads again: `navigation.currentEntry.url` is `extension:<handle>/<route>`.
|
|
824
|
+
- **Components**: the ones of checkout, and those only customer accounts have: `s-avatar`,
|
|
825
|
+
`s-button-group`, `s-customer-account-action`, `s-image-group`, `s-menu`, `s-page`.
|
|
826
|
+
- `?targets=all` adds a panel that lists the customer account targets of each extension, and where
|
|
827
|
+
each one renders.
|
|
828
|
+
- **An uninstalled app** (`POST /__lsf/admin/uninstall`) renders no customer account extension: a
|
|
829
|
+
banner and the panel say "the app is not installed on the shop", and a full page answers `404`
|
|
830
|
+
with that reason. They render again once it is installed ([uninstall](admin.md#tokens)).
|
|
831
|
+
|
|
832
|
+
## Set up the checkout
|
|
833
|
+
|
|
834
|
+
The `checkout` section of the [store data](data-format.md#checkout) sets what Shopify holds
|
|
835
|
+
outside the app: shipping rates, payment methods, the discounts that use your Functions, the
|
|
836
|
+
settings of your other Functions, and a tax rate.
|
|
837
|
+
|
|
838
|
+
```json
|
|
839
|
+
{
|
|
840
|
+
"checkout": {
|
|
841
|
+
"shipping_rates": [
|
|
842
|
+
{ "handle": "standard", "title": "Standard", "price": "4.90" },
|
|
843
|
+
{ "handle": "express", "title": "Express", "price": "12.90" }
|
|
844
|
+
],
|
|
845
|
+
"payment_methods": [
|
|
846
|
+
{ "handle": "card", "name": "Credit card" },
|
|
847
|
+
{ "handle": "cod", "name": "Cash on Delivery" }
|
|
848
|
+
],
|
|
849
|
+
"discounts": [
|
|
850
|
+
{
|
|
851
|
+
"function": "volume-discount",
|
|
852
|
+
"title": "Volume",
|
|
853
|
+
"code": null,
|
|
854
|
+
"classes": ["PRODUCT"],
|
|
855
|
+
"metafields": {
|
|
856
|
+
"$app.function-configuration": { "type": "json", "value": { "minimumQuantity": 3, "percentage": 10 } }
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
],
|
|
860
|
+
"functions": {
|
|
861
|
+
"delivery-rules": {
|
|
862
|
+
"metafields": { "$app.function-configuration": { "type": "json", "value": { "hide": ["express"] } } }
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
"tax_rate": 20
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
- A **discount** is a discount Function with its title, its metafields and, if it has one, the
|
|
871
|
+
code that triggers it. Without a code it is automatic. `classes` lists what it may reduce:
|
|
872
|
+
`PRODUCT`, `ORDER`, `SHIPPING`. All three when omitted.
|
|
873
|
+
- **A discount Function runs only for a discount that names it**, as on Shopify, where the
|
|
874
|
+
merchant creates the discount: one that no discount of the store data (nor of a scenario)
|
|
875
|
+
names never runs, and the home page and `lsf app function run` say so. The demo store of
|
|
876
|
+
`lsf init` has none: add yours here, in a scenario, or for one test in the `data.checkout` of
|
|
877
|
+
`PUT /__lsf/session` (see [testing](testing.md#a-checkout-in-a-test)).
|
|
878
|
+
- A Function that is not a discount (cart transform, delivery or payment customization,
|
|
879
|
+
validation) has its metafields under `functions`, by its handle, and `"enabled"` when the shop
|
|
880
|
+
has an owner of it: see [what runs](#owners-and-scenarios).
|
|
881
|
+
- A metafield is written `"namespace.key"`, with a [typed value](data-format.md#metafields-and-metaobjects):
|
|
882
|
+
the namespace and the key the input query reads, joined by a dot. Shopify's templates read
|
|
883
|
+
`metafield(namespace: "$app", key: "function-configuration")`, and name it in
|
|
884
|
+
`[extensions.input.variables]` (`namespace = "$app"`, `key = "function-configuration"`):
|
|
885
|
+
that is `"$app.function-configuration": {"type": "json", "value": {…}}`. A namespace
|
|
886
|
+
`$app:<name>` keeps its colon: `"$app:config.settings"` is the key `settings` of
|
|
887
|
+
`$app:config`. Shopify's list of `{namespace, key, type, value}`, as `metafieldsSet` takes
|
|
888
|
+
it, is read too:
|
|
889
|
+
`"metafields": [{"namespace": "$app", "key": "function-configuration", "type": "json", "value": "{\"percentage\": 10}"}]`.
|
|
890
|
+
- The **settings of a UI extension** are what the merchant fills in in the checkout editor:
|
|
891
|
+
`checkout.extensions.<handle>.settings`, by the `key` of each field of its
|
|
892
|
+
`[extensions.settings]`, read as `shopify.settings` (`useSettings`). A scenario's replace the
|
|
893
|
+
store data's for the extensions it names. A field without a value is left out, as on Shopify.
|
|
894
|
+
`validate` and the home page warn about a key the extension does not declare, a value not of
|
|
895
|
+
its field's type (`number_integer`: a whole number, `boolean`, text and dates: a string) or
|
|
896
|
+
not among its `choices`:
|
|
897
|
+
`{"checkout": {"extensions": {"gift-banner": {"settings": {"title": "Free gift wrap", "max_items": 3}}}}}`.
|
|
898
|
+
- A **payment method** is in the list of the page by default (`PAYMENT_METHOD`). Its
|
|
899
|
+
`placements` can put it among the express buttons above the contact section
|
|
900
|
+
(`["ACCELERATED_CHECKOUT"]`), or in both, as PayPal with PayPal Express on
|
|
901
|
+
(`["PAYMENT_METHOD", "ACCELERATED_CHECKOUT"]`).
|
|
902
|
+
- `tax_rate` is a percentage added to the discounted subtotal.
|
|
903
|
+
- A line bought with a [selling plan](data-format.md#selling-plans) that defers a part of the
|
|
904
|
+
price is [paid in two times](#deferred-payments).
|
|
905
|
+
|
|
906
|
+
Without the section: one shipping rate, "Standard" at 5.00, the payment methods "Credit card",
|
|
907
|
+
"Cash on Delivery" and "Bank Deposit", and no tax.
|
|
908
|
+
|
|
909
|
+
The rest of what a Function reads comes from the other sections of the store data:
|
|
910
|
+
|
|
911
|
+
- `buyerIdentity.purchasingCompany` (company, location and `contact`, with their dates, locale
|
|
912
|
+
and, for a location, `ordersCount` and `totalSpent`) from the
|
|
913
|
+
[companies and the customers](data-format.md#b2b-customers).
|
|
914
|
+
- `localization.market` and `MailingAddress.market` from
|
|
915
|
+
[`localization.markets`](data-format.md#localization): a country in no market is a market of
|
|
916
|
+
its own.
|
|
917
|
+
- `shop.metaobject(handle: {type:, handle:})` from the
|
|
918
|
+
[metaobjects](data-format.md#metafields-and-metaobjects).
|
|
919
|
+
- `paymentMethods { placements }` from `payment_methods[].placements` (the list of the checkout
|
|
920
|
+
by default). A payment customization that hides a method for one placement only keeps the
|
|
921
|
+
other, and a method left in no placement is removed.
|
|
922
|
+
- `cart.deliverableLines` is the lines that ship: the ones the delivery group holds.
|
|
923
|
+
|
|
924
|
+
## Deferred payments
|
|
925
|
+
|
|
926
|
+
A selling plan whose `checkout_charge` is less than the price (a pre-order with a deposit, `0`
|
|
927
|
+
for try before you buy) is paid in part at checkout, and the rest later. The storefront cart
|
|
928
|
+
reports the part paid at checkout as `cart.checkout_charge_amount`; the checkout does the same
|
|
929
|
+
and splits what the buyer owes:
|
|
930
|
+
|
|
931
|
+
```text
|
|
932
|
+
Subtotal · 3 items 96.00
|
|
933
|
+
Shipping 4.90
|
|
934
|
+
Total 100.90
|
|
935
|
+
Due today 38.50
|
|
936
|
+
Due later 62.40
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
- `total` is what the order costs in all, as without a plan. *Due today* (`cost.dueNow`) is
|
|
940
|
+
what the buyer pays, and *due later* (`cost.dueLater`) what the plan defers. The page, the
|
|
941
|
+
Pay button of the Shop skin and the payment line of the thank-you page charge `dueNow`.
|
|
942
|
+
- A discount comes off the balance first: it only reduces the deposit once the balance is gone.
|
|
943
|
+
A line with a balance has `dueLater` in the state, and what it owes today is its `total` less
|
|
944
|
+
that.
|
|
945
|
+
- Shipping is due today. Tax follows the amount it is on: the tax of the balance is due later.
|
|
946
|
+
- `cost.checkoutChargeAmount` is the subtotal less the balances, before discounts, shipping and
|
|
947
|
+
tax: the `cart.checkout_charge_amount` of the cart, for the same lines.
|
|
948
|
+
- The order keeps `order.totalReceived` (what was due today) and `order.totalOutstanding` (what
|
|
949
|
+
is due later), named as the `totalReceivedSet` and `totalOutstandingSet` of Shopify's `Order`.
|
|
950
|
+
The thank-you page says the remaining balance is due later. Nothing is charged later.
|
|
951
|
+
- A cart transform cannot change a line with a selling plan, as on Shopify.
|
|
952
|
+
|
|
953
|
+
Shopify documents none of this split in the places an app can read. The Cost API of checkout UI
|
|
954
|
+
extensions (2026-10 and 2027-01) has the subtotal, shipping, tax and total (`totalAmount`, the
|
|
955
|
+
whole order) and no due-today amount; a line's `merchandise.sellingPlan` has `id` and
|
|
956
|
+
`recurringDeliveries`; the input of a Function has the selling plan (`id`, `name`,
|
|
957
|
+
`description`, `recurringDeliveries`) and its price adjustments, no checkout charge or balance.
|
|
958
|
+
The `shopify` global and the Function input are the same as without a plan, and an extension
|
|
959
|
+
reads what is due today from `cost.dueNow` of the page state only, not from the API. What
|
|
960
|
+
Shopify's help center says is that the deposit due at checkout shows in the order summary, and
|
|
961
|
+
that the remaining payment is collected later. How discounts, shipping and tax split between
|
|
962
|
+
today and later, and the labels, are not documented: those above are this checkout's choices.
|
|
963
|
+
|
|
964
|
+
## What runs
|
|
965
|
+
|
|
966
|
+
Every time the page changes something, the Functions run again, in the order Shopify runs
|
|
967
|
+
them:
|
|
968
|
+
|
|
969
|
+
1. cart transform (`cart.transform.run`)
|
|
970
|
+
2. product and order discounts (`cart.lines.discounts.generate.run`, and the older
|
|
971
|
+
`purchase.product-discount.run` and `purchase.order-discount.run`)
|
|
972
|
+
3. the delivery groups, from `shipping_rates`
|
|
973
|
+
4. delivery customization (`cart.delivery-options.transform.run`, `purchase.delivery-customization.run`)
|
|
974
|
+
5. shipping discounts (`cart.delivery-options.discounts.generate.run`, `purchase.shipping-discount.run`)
|
|
975
|
+
6. payment customization (`cart.payment-methods.transform.run`, `purchase.payment-customization.run`)
|
|
976
|
+
7. validation (`cart.validations.generate.run`, `purchase.validation.run`)
|
|
977
|
+
|
|
978
|
+
Not every Function of the app runs: see [owners and scenarios](#owners-and-scenarios).
|
|
979
|
+
|
|
980
|
+
A `linesMerge` of a cart transform takes `quantity` units of each cart line it names (no more than
|
|
981
|
+
the line holds). The merged line is as many bundles as the greatest common divisor of those
|
|
982
|
+
quantities, and each component is in a bundle as many times as its quantity says divided by that
|
|
983
|
+
number: four tees and six totes are two bundles of two tees and three totes. The price of a bundle
|
|
984
|
+
is the sum of the prices of its components, `percentageDecrease` applies to it, and the line costs
|
|
985
|
+
the price of a bundle times its quantity. The discounts, the validation and the `shopify` global
|
|
986
|
+
see that quantity (`lineComponents[].quantity` is the number of units of the component in the whole
|
|
987
|
+
line, as for `lineExpand`). The units of a cart line that the merge does not take stay in a line of
|
|
988
|
+
their own. Changing the quantity of a merged line in the checkout scales the lines it is made of,
|
|
989
|
+
by bundles. Shopify's documentation gives the examples (every merged quantity is the smallest of
|
|
990
|
+
the lines) and the units that stay as "a separate item", but does not say how the quantity of the
|
|
991
|
+
merged line follows from the quantities: the greatest common divisor is the one rule that keeps
|
|
992
|
+
every merged unit in a whole number of identical bundles.
|
|
993
|
+
|
|
994
|
+
Several delivery customizations, and several payment customizations, run one after the other in the
|
|
995
|
+
order of their folders in `extensions/` (by name), and each sees what the ones before it did: a
|
|
996
|
+
method that one hides is not in the input of the next, and a method one renames comes to the next
|
|
997
|
+
with its new name; when two rename the same method, the last one wins. Shopify's documentation does
|
|
998
|
+
not say how several of them combine (the Delivery Customization and Payment Customization Function
|
|
999
|
+
API pages only say that a store can activate 25 of each, and the cart transform page is the only
|
|
1000
|
+
one that says how the operations of several Functions are merged), so lsf keeps this chain. A store
|
|
1001
|
+
with one customization of each kind, the common case, sees no difference.
|
|
1002
|
+
|
|
1003
|
+
Each Function sees the checkout as the steps before it left it, as on Shopify. A Function that
|
|
1004
|
+
asks for `cart.discountApplications`, `CartLine.discountAllocations` or
|
|
1005
|
+
`CartDeliveryGroup.discountAllocations` reads the discounts applied so far: the cart transform
|
|
1006
|
+
sees none, the delivery customization the product and order discounts, the payment
|
|
1007
|
+
customization and the validation all of them, shipping included. The discount Functions are the
|
|
1008
|
+
exception: Shopify runs them concurrently, with no knowledge of one another, so none of them
|
|
1009
|
+
(`cart.lines.discounts.generate.run`, `cart.delivery-options.discounts.generate.run` and the
|
|
1010
|
+
legacy ones) sees a discount, whether it comes from another discount or an earlier stage. Their
|
|
1011
|
+
answers are applied once all of them have run: product discounts first, then order discounts on
|
|
1012
|
+
what is left of the lines, then shipping, so the order of the discounts in the configuration
|
|
1013
|
+
changes nothing (see [how discounts stack](#how-discounts-stack)). An application has the value of the
|
|
1014
|
+
discount (`PricingPercentageValue` or `MoneyV2`), its allocation method (`EACH` for a percentage,
|
|
1015
|
+
`ACROSS` for a fixed amount shared between the lines), what it targets (`EXPLICIT` for the lines
|
|
1016
|
+
the Function named, `ALL` for the whole order, `ENTITLED` when an order discount leaves lines
|
|
1017
|
+
out) and what it took off in total; its `metafield` is one of the discount's.
|
|
1018
|
+
|
|
1019
|
+
### Owners and scenarios
|
|
1020
|
+
|
|
1021
|
+
On Shopify, a Function runs only through an object of the shop that runs it, its **owner**, which
|
|
1022
|
+
the app creates once installed: a payment customization (`paymentCustomizationCreate`), a
|
|
1023
|
+
delivery customization, a cart and checkout validation (`validationCreate`), a cart transform
|
|
1024
|
+
(`cartTransformCreate`); a discount Function, through a discount. A shop the app was just
|
|
1025
|
+
installed on has none, and its checkout runs none of the app's Functions until the app sets
|
|
1026
|
+
them up. lsf follows that rule at the shop's checkout (`/checkout` from the storefront's cart,
|
|
1027
|
+
and a scenario that names no `extensions`):
|
|
1028
|
+
|
|
1029
|
+
| Function | Runs when |
|
|
1030
|
+
|---|---|
|
|
1031
|
+
| payment customization | the shop has an enabled `PaymentCustomization` of it |
|
|
1032
|
+
| delivery customization | the shop has an enabled `DeliveryCustomization` of it |
|
|
1033
|
+
| cart and checkout validation | the shop has an enabled `Validation` of it (`validationCreate` takes `enable`, `false` by default) |
|
|
1034
|
+
| cart transform | the shop has a `CartTransform` of it (it has no `enabled`: deleting it stops it) |
|
|
1035
|
+
| discount | a discount names it that is active by the shop's clock (`startsAt` ≤ now < `endsAt`), automatic or with a code the buyer entered |
|
|
1036
|
+
| fulfillment constraints, order routing, pickup generators | never: the local checkout lists them and does not run them |
|
|
1037
|
+
|
|
1038
|
+
The owners come from two places:
|
|
1039
|
+
|
|
1040
|
+
- **The app**, through the [Admin API](admin.md): what it creates, updates (`enabled`), deletes
|
|
1041
|
+
or turns on and off (`paymentCustomizationActivation`) is what the next checkout runs, with the
|
|
1042
|
+
metafields it gives them.
|
|
1043
|
+
- **The store data**, for a shop already set up: `"enabled": true` on the Function's entry of
|
|
1044
|
+
`checkout.functions` (`false`: an owner that is turned off). An entry without `enabled` is
|
|
1045
|
+
settings alone: the shop has no owner of the Function.
|
|
1046
|
+
|
|
1047
|
+
```json
|
|
1048
|
+
{ "checkout": { "functions": { "payment-rules": { "enabled": true, "metafields": { "$app.function-configuration": { "type": "json", "value": { "hide": ["paypal"] } } } } } } }
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
lsf creates no owner at install: `paymentCustomizations` answers none, as Shopify does, and an
|
|
1052
|
+
app that checks whether it is set up sees that it is not.
|
|
1053
|
+
|
|
1054
|
+
**Uninstalled, the app runs nothing.** Shopify removes an uninstalled app's Functions, and a
|
|
1055
|
+
Function goes with its owners: `POST /__lsf/admin/uninstall` (or `appUninstall`) deletes every
|
|
1056
|
+
owner of the app's Functions, those of the store data and the app discounts included. While the
|
|
1057
|
+
app is not installed, the shop's checkout runs none of its Functions, whatever a scenario
|
|
1058
|
+
declares or names in its `extensions`, and lists each as not run because "the app is not
|
|
1059
|
+
installed on the shop". Installing it again brings no owner back: the app creates them, as after
|
|
1060
|
+
a first install ([uninstall](admin.md#tokens)). Nor does the checkout load any of the app's UI
|
|
1061
|
+
extensions while it is not installed, the thank-you page of an order placed before included: a
|
|
1062
|
+
banner, the Functions dialog ("Extensions not loaded") and `?targets=all` say why, and the state
|
|
1063
|
+
has `"extensions": []` with `"uninstalled": {"reason", "extensions"}`. They render again once
|
|
1064
|
+
the app is installed again.
|
|
1065
|
+
|
|
1066
|
+
**Trying a Function alone** does not need an owner. A [scenario](#scenarios) that names it in
|
|
1067
|
+
`extensions` runs it, and so does `lsf app function run <handle>`, with the metafields of
|
|
1068
|
+
`checkout.functions` (the scenario's, or the store data's). That is how the scenarios of
|
|
1069
|
+
`app init` try each Function; the shop's checkout shows what the app has set up. `--extension`
|
|
1070
|
+
of `app dev` only narrows the extensions: the shop's owners still decide which Functions run.
|
|
1071
|
+
|
|
1072
|
+
A Function the shop's checkout leaves out is said: the home page warns that the shop has no
|
|
1073
|
+
enabled owner of it when no scenario runs it either (the scenarios of `app init` name each
|
|
1074
|
+
Function, so it says nothing after them), the "Functions" dialog of the checkout lists it under "Functions not run",
|
|
1075
|
+
with why, and so does `lsf app function run` (`Not run: …`; `idle` in JSON).
|
|
1076
|
+
|
|
1077
|
+
A scenario added or edited while lsf runs is read again without seeding the shops of the Admin
|
|
1078
|
+
API: the owners the app created stay ([when the data changes on disk](cli.md#when-the-data-changes-on-disk)).
|
|
1079
|
+
|
|
1080
|
+
### How discounts stack
|
|
1081
|
+
|
|
1082
|
+
Discounts apply class after class, as on Shopify: product discounts first, then order
|
|
1083
|
+
discounts on the subtotal they left, then shipping. Within a class:
|
|
1084
|
+
|
|
1085
|
+
- **Order discounts.** Every percentage is a share of the subtotal after the product discounts,
|
|
1086
|
+
not of what the order discounts before it left: 10% and 20% off 100.00 take 10.00 and 20.00,
|
|
1087
|
+
70.00 is left. The fixed amounts come off afterwards, from what is left, and never take the
|
|
1088
|
+
order below zero (a percentage and a $20 discount on 350.00: 10% leaves 315.00, then 295.00).
|
|
1089
|
+
- **Product discounts on one line.** The percentages come off before the fixed amounts. A
|
|
1090
|
+
percentage is a share of the price of the line before any discount, so 10% and 20% off a line
|
|
1091
|
+
of 100.00 take 10.00 and 20.00. A fixed amount never takes more than the line has left.
|
|
1092
|
+
- **A fixed amount across several lines** (`appliesToEachItem: false`) is taken once: it is
|
|
1093
|
+
shared between the lines in proportion to their prices, and what a line cannot take (a
|
|
1094
|
+
discount before it took its price) goes to the others, so the amount is taken in full as long
|
|
1095
|
+
as the lines have that much left. 30.00 across a free 32.00 line and a 24.00 line takes the
|
|
1096
|
+
24.00 of the second.
|
|
1097
|
+
- **Shipping discounts.** One applies per order, the best one: the one that takes the most off
|
|
1098
|
+
the delivery option the buyer selected (then off all the options). The others are not applied
|
|
1099
|
+
and are not dropped silently: the Functions dialog lists them under "Discounts not applied"
|
|
1100
|
+
(`skippedDiscounts` in the state, and a `discount not applied` line in `lsf app function run`)
|
|
1101
|
+
with their amount and why. 50% off a 4.90 Standard rate and 3.00 off it: the 3.00 applies,
|
|
1102
|
+
the 50% (2.45) is listed. Free Express does not beat 1.00 off Standard while the buyer has
|
|
1103
|
+
Standard selected, and wins when they select Express. The candidates of one discount are
|
|
1104
|
+
one discount: they all apply, one after the other. A tie (the same amount) goes to the
|
|
1105
|
+
discount that comes first by Function, title, message and code, not by its place in the
|
|
1106
|
+
configuration.
|
|
1107
|
+
- **A discount of 0.** A candidate whose value is 0 (a `percentage` of 0, a `fixedAmount` of
|
|
1108
|
+
0.00) takes nothing off and still applies: its code is accepted (`applicable` in
|
|
1109
|
+
`discountCodes`), and it shows at 0 on the lines or the delivery group it targets, in
|
|
1110
|
+
`discounts` and `discountAllocations`, in the page and in `lsf app function run`. A Function
|
|
1111
|
+
answers one to accept a code before the line it stands for (a gift) reaches the cart. A
|
|
1112
|
+
shipping discount of 0 applies to the rate the buyer selected (none is before an address with a
|
|
1113
|
+
country) when no other one takes something off that rate, and is listed as not applied
|
|
1114
|
+
otherwise: it beats one that only cuts the rates the buyer did not select, which would apply
|
|
1115
|
+
nothing. An automatic discount (one without a code) of 0 applies the same way and shows at 0:
|
|
1116
|
+
the playground's `discount-vip-customer`, whose percentage comes from settings, shows "VIP: 0%
|
|
1117
|
+
off −€0.00" while its settings are not set. A candidate of more than 0 that finds nothing left
|
|
1118
|
+
to take off (a line another discount made free) is not applied.
|
|
1119
|
+
|
|
1120
|
+
An amount is shared between the lines in proportion to what they cost, to the cent, and the
|
|
1121
|
+
allocations of the lines (`discountAllocations`, in the page and for the Functions that run
|
|
1122
|
+
later) add up to the discount.
|
|
1123
|
+
|
|
1124
|
+
**What is guaranteed, and what is not.** Product and order discounts come out the same whatever
|
|
1125
|
+
the order of the discounts in the configuration, of the `[[extensions]]` of the app and of the
|
|
1126
|
+
candidates in an answer: the checkout applies the candidates of a class in an order of its own.
|
|
1127
|
+
Percentages first, the larger one first; then the fixed amounts, the one that covers fewer lines
|
|
1128
|
+
first. The second rule is what makes overlapping fixed amounts predictable: two lines of 100.00,
|
|
1129
|
+
a fixed 100.00 across both and a fixed 100.00 on the second alone leave 0.00 in either
|
|
1130
|
+
configuration order (the narrow one first takes the second line, the general one takes the
|
|
1131
|
+
first), and not 50.00 in one and 0.00 in the other. Candidates that cover the same lines are
|
|
1132
|
+
told apart by what they would take from each line, their amount, their targets with the
|
|
1133
|
+
quantities they name, Function, title, code and message, never by their place in the
|
|
1134
|
+
configuration or in the answer; two that agree on all of these take the same from the same
|
|
1135
|
+
lines, and only their metafields tell them apart. The order is this
|
|
1136
|
+
checkout's choice, and so is the sharing of a fixed amount between lines: Shopify documents
|
|
1137
|
+
neither for overlapping amounts. It is a fixed order, not the one that takes the most in every
|
|
1138
|
+
case.
|
|
1139
|
+
|
|
1140
|
+
Shopify documents the order of the classes, the percentages of order discounts being all
|
|
1141
|
+
calculated on the subtotal, the percentage before the fixed amount in the order and on a line, and
|
|
1142
|
+
the fixed amounts after (https://help.shopify.com/en/manual/discounts/discount-combinations).
|
|
1143
|
+
It does not say how two percentages on one line combine, how two fixed amounts combine, nor
|
|
1144
|
+
whether a fixed amount may exceed what is left: the rules above for those are this checkout's
|
|
1145
|
+
choices. It also says that only one shipping discount applies per order and that, when discounts
|
|
1146
|
+
cannot combine, "the best discount for the customer's cart is always applied". It does not say
|
|
1147
|
+
how the best shipping discount is measured, what happens to a tie, whether the rule holds for
|
|
1148
|
+
each delivery group of a split order (this checkout has one group), nor what to show about the
|
|
1149
|
+
others: measuring by the selected option, breaking ties as above and listing the others are
|
|
1150
|
+
this checkout's choices. Shopify's checkout applies a code whose product discount is 0 and shows
|
|
1151
|
+
it at 0; that an order or a shipping discount of 0 does the same, and that an automatic discount
|
|
1152
|
+
of 0 shows, are this checkout's choices: Shopify does not say what its checkout shows for them.
|
|
1153
|
+
|
|
1154
|
+
### Network access
|
|
1155
|
+
|
|
1156
|
+
A Function with network access has a `.fetch` target next to its `.run` target:
|
|
1157
|
+
`cart.lines.discounts.generate.fetch`, `cart.delivery-options.discounts.generate.fetch` and
|
|
1158
|
+
`cart.validations.generate.fetch` (the Discount and Cart and Checkout Validation APIs, in
|
|
1159
|
+
2026-10 and 2027-01). The checkout runs the `.fetch` target right before its `.run` target, sends
|
|
1160
|
+
the HTTP request it answers from your machine, and gives the response to the `.run` target as
|
|
1161
|
+
`fetchResult` (`status`, `body`, `jsonBody`, `header(name:)`). The request and the response
|
|
1162
|
+
show in the Functions dialog and in `lsf app function run`.
|
|
1163
|
+
|
|
1164
|
+
Shopify's limits apply: `GET` and `POST`; an HTTPS URL (an `http` URL is accepted for
|
|
1165
|
+
`localhost` and loopback addresses, so a server of the app under development can answer); a
|
|
1166
|
+
`policy.readTimeoutMs` from 100 to 2000; 100 KB for the headers and the body of the response; no
|
|
1167
|
+
redirect followed; a UTF-8 body. A request that is out of these limits fails the `.fetch`
|
|
1168
|
+
target, and its `.run` target does not run. A request that cannot be made is a response,
|
|
1169
|
+
as on Shopify: `504` after the timeout, `502` when the connection or the TLS handshake fails
|
|
1170
|
+
or the response is too big, `501` for another charset, with Shopify's body (`504 Gateway
|
|
1171
|
+
Timeout`). A `.fetch` target that answers no request leaves `fetchResult` null, and its input
|
|
1172
|
+
query cannot ask for `fetchResult`.
|
|
1173
|
+
|
|
1174
|
+
Not simulated: the JWT Shopify signs the request with (`x-shopify-request-jwt` needs the secret
|
|
1175
|
+
of the app) and its cache of responses (every run of the Function sends its request, so a page
|
|
1176
|
+
that changes the checkout sends it again). The pickup point and local pickup generators, which
|
|
1177
|
+
have `.fetch` targets too, are not run.
|
|
1178
|
+
|
|
1179
|
+
A validation Function can check a rule at the placing of the order only: `buyerJourney.step` is
|
|
1180
|
+
`CHECKOUT_INTERACTION` when the page loads (and for `.json`) or changes (`update`, a line included),
|
|
1181
|
+
and `CHECKOUT_COMPLETION` for `complete` alone. The errors of an interaction show on the page and
|
|
1182
|
+
refuse nothing; `complete` runs the Functions again, as a completion, and only its errors refuse
|
|
1183
|
+
the order. (Shopify's documentation does not say which step blocks; this is how the local checkout
|
|
1184
|
+
treats them.)
|
|
1185
|
+
|
|
1186
|
+
The input of each Function is the result of its input query, run against the cart, the buyer and
|
|
1187
|
+
the store data. A Function that fails to run adds an error that blocks the order.
|
|
1188
|
+
|
|
1189
|
+
The "Functions" badge in the bottom left corner of every page (the checkout, the thank-you page,
|
|
1190
|
+
`?targets=all`) says how many Functions ran, and turns red when one failed or the checkout has
|
|
1191
|
+
errors. Below 1644px wide it is just "ƒ" and the number of runs, so that it stays clear of the
|
|
1192
|
+
checkout; its tooltip says the rest. It opens the Functions dialog, which takes most of the window (all of it on a narrow
|
|
1193
|
+
screen): the runs on the left, and for the one picked its error, its logs, its instructions
|
|
1194
|
+
(Shopify stops a Function past 11 million) and its input and output side by side, each with a
|
|
1195
|
+
Copy button; a `.fetch` run adds its HTTP request and response. It opens on the first run that
|
|
1196
|
+
failed. Its Overview lists the errors that block the checkout, the discounts a Function answered
|
|
1197
|
+
that were not applied, the checkout UI extensions, and a link to the other skin. Escape, the
|
|
1198
|
+
close button or a click outside closes it.
|
|
1199
|
+
|
|
1200
|
+
`?functions=` on the URL of a checkout opens the dialog as the page loads, which is what the
|
|
1201
|
+
links of the [home page](#the-home-page) problems do. `/checkout?scenario=<name>` and
|
|
1202
|
+
`/checkout` keep it through their redirect, as they keep `variant` and `targets`.
|
|
1203
|
+
|
|
1204
|
+
| Value | The dialog opens on |
|
|
1205
|
+
|---|---|
|
|
1206
|
+
| `<handle>` | the run of that Function that failed first, else its first run |
|
|
1207
|
+
| `<handle>:<target>` | the same, among the runs of that target |
|
|
1208
|
+
| empty (`?functions=`) | the Overview |
|
|
1209
|
+
|
|
1210
|
+
A value with no run in the checkout (a Function that does not run there, an unknown handle, a
|
|
1211
|
+
checkout UI extension) opens the Overview: what it names is outlined there (an error, an
|
|
1212
|
+
extension), or a line says it did not run or load in this checkout. The page then drops the
|
|
1213
|
+
parameter from its URL (`history.replaceState`), so a reload does not open the dialog again;
|
|
1214
|
+
once closed, nothing reopens it. The run stays picked across updates, as a run picked by hand
|
|
1215
|
+
does.
|
|
1216
|
+
|
|
1217
|
+
The checkout UI extensions render where their targets say: in the page, one sandboxed frame
|
|
1218
|
+
each. They read the checkout through the `shopify` global and change it with the `apply*Change`
|
|
1219
|
+
methods, `buyerJourney.intercept` can block the payment, and they draw with the Polaris web
|
|
1220
|
+
components (`s-text`, `s-button`, ...), as from API version 2025-10. React and remote-ui
|
|
1221
|
+
extensions (2025-07 and older) do not run: lsf runs 2026-10 and 2027-01 only.
|
|
1222
|
+
|
|
1223
|
+
What an extension throws, while its bundle loads, when a target renders or in a listener, and
|
|
1224
|
+
the promises it rejects with nobody to catch them, show above its frame with their message and
|
|
1225
|
+
stack, and in the overview of the Functions dialog. The frame keeps an opaque origin. The page
|
|
1226
|
+
fetches the bundle (same origin) and hands its text to the frame, which runs it from a `blob:`
|
|
1227
|
+
URL of its own: the browser does not hide those errors ("Script error."), and lsf serves the
|
|
1228
|
+
bundles without CORS, so another site cannot read them.
|
|
1229
|
+
|
|
1230
|
+
An extension needs a supported `api_version`, or it is reported as an error and not shown. From
|
|
1231
|
+
2027-01, each `[[extensions.metafields]]` entry needs an `owner_type` (`CART`, `CUSTOMER`,
|
|
1232
|
+
`ORDER`, ...), or the loader reports an error and the page says why it does not load the
|
|
1233
|
+
extension.
|
|
1234
|
+
|
|
1235
|
+
`shopify.appMetafields` has the metafields the extension declares: those of
|
|
1236
|
+
`[[extensions.metafields]]`, and those of `[[extensions.targeting.metafields]]` at the target
|
|
1237
|
+
they are declared for. Each entry is a metafield with the resource it belongs to, its `target`:
|
|
1238
|
+
a `type` (`shop`, `product`, `variant`, `customer`, `company`, `companyLocation`, `cart`, and
|
|
1239
|
+
`order` on the order status page) and the `id` of the resource, a GID. They are read from the store data, with what the backend of the
|
|
1240
|
+
app sets through the [Admin API](admin.md) (`metafieldsSet`): the shop's, those of the products
|
|
1241
|
+
and variants of the lines (and of the components of a bundle a cart transform made), of the
|
|
1242
|
+
customer logged in and of the company and location they buy for, and the cart's, which the
|
|
1243
|
+
extensions write with `applyMetafieldChange`. A declaration with an `owner_type` reads that
|
|
1244
|
+
resource only, one without reads every resource that has the metafield. The app's own namespace
|
|
1245
|
+
is written `$app` (or `$app:<name>`) in the toml, in the store data and in what the extension
|
|
1246
|
+
reads; Shopify's `app--<id>--<name>`, which the Admin API writes, is the same namespace. The toml
|
|
1247
|
+
takes `$app` only, as on Shopify: a declaration written `app--<id>--<name>` reads nothing, and the
|
|
1248
|
+
loader warns with the spelling to use.
|
|
1249
|
+
|
|
1250
|
+
```toml
|
|
1251
|
+
[[extensions.metafields]]
|
|
1252
|
+
namespace = "$app"
|
|
1253
|
+
key = "gift-codes"
|
|
1254
|
+
owner_type = "SHOP"
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
```json
|
|
1258
|
+
{ "shop": { "metafields": { "$app": { "gift-codes": { "type": "json", "value": ["GIFT-1"] } } } } }
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
```js
|
|
1262
|
+
// [{target: {type: 'shop', id: 'gid://shopify/Shop/…'},
|
|
1263
|
+
// metafield: {namespace: '$app', key: 'gift-codes', value: '["GIFT-1"]', type: 'json', valueType: 'json_string'}}]
|
|
1264
|
+
shopify.appMetafields.value;
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
On the order status page of the customer account, the products and variants are those of the
|
|
1268
|
+
order, the company location is the one the order was placed for (whichever the buyer buys for
|
|
1269
|
+
now; an order of the store data records none, and reads the customer's first location), and the
|
|
1270
|
+
cart is the one of the order placed in the local checkout. As in Shopify's types, a `value` there
|
|
1271
|
+
is typed by its `valueType` (`true`, `2`, `1.5`; a JSON value stays serialized), where the
|
|
1272
|
+
checkout's is the text whatever the type (`"true"`).
|
|
1273
|
+
|
|
1274
|
+
The order's own metafields are `order` entries (`target: {type: 'order', id: 'gid://shopify/Order/…'}`),
|
|
1275
|
+
for a declaration of `owner_type = "ORDER"` or of no owner. They are read from the order in the
|
|
1276
|
+
[Admin API](admin.md#what-the-storefront-and-the-checkout-see): what the backend sets on the
|
|
1277
|
+
order (`metafieldsSet`), on an order placed in the local checkout or on one of the store data,
|
|
1278
|
+
and the cart metafields copied onto an order placed here. As on
|
|
1279
|
+
Shopify, a cart metafield is copied when the order is placed if an ORDER definition has its
|
|
1280
|
+
namespace and key with `cartToOrderCopyable` enabled, made by the backend or declared in
|
|
1281
|
+
`shopify.app.toml`; without one, it stays the cart's only:
|
|
1282
|
+
|
|
1283
|
+
```toml
|
|
1284
|
+
# shopify.app.toml
|
|
1285
|
+
[order.metafields.app.gift_message]
|
|
1286
|
+
type = "single_line_text_field"
|
|
1287
|
+
capabilities.cart_to_order_copyable = true
|
|
1288
|
+
|
|
1289
|
+
# shopify.extension.toml
|
|
1290
|
+
[[extensions.metafields]]
|
|
1291
|
+
namespace = "$app"
|
|
1292
|
+
key = "gift_message"
|
|
1293
|
+
owner_type = "ORDER"
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
The checkout and the thank-you page have no `order` entry (Shopify's `AppMetafieldEntryTarget`
|
|
1297
|
+
of checkout UI extensions has no such type): an `ORDER` declaration reads nothing there, without
|
|
1298
|
+
a warning. Shopify lists `CART` as an owner type of checkout UI extensions only and `ORDER` of
|
|
1299
|
+
customer account UI extensions only; lsf takes both on either, as one extension can target both.
|
|
1300
|
+
|
|
1301
|
+
## Run a Function without a server
|
|
1302
|
+
|
|
1303
|
+
```bash
|
|
1304
|
+
npx local-storefront app function run volume-discount --app ../my-app --scenario discount-automatic
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
Builds the input of each Function from the store data and the [scenario](#scenarios), runs them
|
|
1308
|
+
in checkout order, and prints each input, output and log with the checkout that results. The
|
|
1309
|
+
Functions it names run whether or not the shop has an owner of them, and a named one that the
|
|
1310
|
+
checkout of the store data would leave out (its payment customization is disabled, or there is
|
|
1311
|
+
none) is said first (`Named: …`; `forced` in JSON), so that its run is not taken for the
|
|
1312
|
+
checkout's. It reads the store data on disk, not a running lsf: a customization the app created
|
|
1313
|
+
or enabled there through the Admin API is not seen. To see what a running lsf's checkout does, open
|
|
1314
|
+
its checkout (`/__lsf/checkouts`, a scenario), whose debug panel lists each run.
|
|
1315
|
+
Without a handle, it runs those the checkout runs (an enabled owner, a discount, or the
|
|
1316
|
+
`extensions` of the scenario), and the others are listed as not run, with why. Without `--scenario` or `--session`, the cart is the
|
|
1317
|
+
`session` of the store data, empty in the demo store: a Function that runs gets an empty cart
|
|
1318
|
+
and changes nothing. Give `--scenario`.
|
|
1319
|
+
|
|
1320
|
+
```text
|
|
1321
|
+
== scenario discount-automatic: An automatic discount
|
|
1322
|
+
|
|
1323
|
+
== volume-discount · cart.lines.discounts.generate.run (cart-lines-discounts-generate-run)
|
|
1324
|
+
input:
|
|
1325
|
+
{ … }
|
|
1326
|
+
output:
|
|
1327
|
+
{ … }
|
|
1328
|
+
logs:
|
|
1329
|
+
Canvas Tote Bag: 6 units
|
|
1330
|
+
Ceramic Mug: 3 units
|
|
1331
|
+
instructions: 337817
|
|
1332
|
+
|
|
1333
|
+
== checkout
|
|
1334
|
+
6 × Canvas Tote Bag 144.00 USD → 129.60 USD
|
|
1335
|
+
3 × Ceramic Mug (Oat) 66.00 USD → 59.40 USD
|
|
1336
|
+
2 × Linen Overshirt (S) 136.00 USD
|
|
1337
|
+
discount 10% off 3 or more: -21.00 USD (Product, volume-discount)
|
|
1338
|
+
delivery Standard (standard): 5.00 USD
|
|
1339
|
+
payment: Credit card [PAYMENT_METHOD], Cash on Delivery [PAYMENT_METHOD], Bank Deposit [PAYMENT_METHOD]
|
|
1340
|
+
subtotal 346.00 USD · discounts -21.00 USD · shipping 0.00 USD · tax 0.00 USD · total 325.00 USD
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
`*` marks the selected shipping rate: none until the address has a country (the scenario's
|
|
1344
|
+
`entered.shipping_address`, or `--checkout`), so the shipping is not in the total yet. Each
|
|
1345
|
+
payment method has its placements: `[ACCELERATED_CHECKOUT]` is an express button.
|
|
1346
|
+
|
|
1347
|
+
| Option | What it does |
|
|
1348
|
+
|---|---|
|
|
1349
|
+
| `--scenario <name>` | A [scenario](#scenarios) of the store data: its cart, buyer, language, `checkout` settings and the extensions it names. `--session`, `--checkout` and `--locale` go on top of it. |
|
|
1350
|
+
| `--session <file or JSON>` | The session to run for: a [session](data-reference.md#session) with a `cart`, a `customer`, a `country`. Defaults to the `session` of the store data. |
|
|
1351
|
+
| `--checkout <file or JSON>` | What the buyer entered: `email`, `phone`, `shippingAddress`, `selectedDelivery`, `selectedPayment`, `discountCodes`, `metafields`. |
|
|
1352
|
+
| `--locale <iso>` | The language of the buyer, one of the store's (`fr`, `pt-BR`): Functions read it as `localization.language`. Defaults to the primary language. |
|
|
1353
|
+
| `--step interaction\|completion` | The step of the buyer journey, which validation Functions read as `buyerJourney.step`: `completion` (default) is the placing of the order, `interaction` is the page and its updates. |
|
|
1354
|
+
| `--format json` | The checkout as the page reads it, with every run in `runs`. For tools. |
|
|
1355
|
+
| `--build` | Builds the extensions first, as `lsf app build` does: each Function (its build command, or lsf's build of a JavaScript Function), then each UI extension that names its sources. |
|
|
1356
|
+
| `--input <file>` | Runs one Function on this input instead, as `shopify app function run --input` does: a JSON file, or `-` for stdin (see below). |
|
|
1357
|
+
| `--export <name>` | The target to run, by its Wasm export or its name, when the Function has several: for `--input` and `--save-input`. |
|
|
1358
|
+
| `--save-input <file>` | Writes the input lsf built for the run of the Function named to this file. |
|
|
1359
|
+
|
|
1360
|
+
It exits with `1` when a Function fails to run (no module, a trap, an output that does not
|
|
1361
|
+
parse), or when a Function named on the command line did not run: it says why (no discount
|
|
1362
|
+
names a discount Function, the cart is empty, the scenario's `extensions` leaves it out…).
|
|
1363
|
+
Errors that a validation Function reports are part of the checkout, not a failure.
|
|
1364
|
+
|
|
1365
|
+
### Run a Function on an input
|
|
1366
|
+
|
|
1367
|
+
`--save-input` keeps the input lsf builds for a scenario, and `--input` runs a Function on an
|
|
1368
|
+
input, without the store data, as `shopify app function run --input` does:
|
|
1369
|
+
|
|
1370
|
+
```bash
|
|
1371
|
+
npx local-storefront app function run volume-discount --scenario discount-code \
|
|
1372
|
+
--export cart.lines.discounts.generate.run --save-input tests/fixtures/code.json
|
|
1373
|
+
npx local-storefront app function run volume-discount --input tests/fixtures/code.json
|
|
1374
|
+
cat input.json | npx local-storefront app function run volume-discount --input -
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
It prints the run (input, output, logs, instructions), and `--format json` the run as JSON. The
|
|
1378
|
+
output is checked as in a checkout: against the schema of the API version, and the instruction
|
|
1379
|
+
limit. A fixture of the unit tests of Shopify's templates (`tests/fixtures/*.json`,
|
|
1380
|
+
`{"payload": {"export", "target", "input", "output"}}`) runs the target it names, and fails
|
|
1381
|
+
when its `output` is not the run's, with each path that differs (it says `the output is the one
|
|
1382
|
+
the fixture expects` when it is). Its `target` and `export` must be the Function's: a wrong one
|
|
1383
|
+
is an error, not left aside. A Function with
|
|
1384
|
+
several targets needs `--export` (its export, `cart-lines-discounts-generate-run`, or its name).
|
|
1385
|
+
In the Function's folder, with a fixture in it, or in an app with one Function, the handle can
|
|
1386
|
+
be left out. `--input` with a folder runs each of its `*.json` fixtures and exits with `1` when
|
|
1387
|
+
one fails, as the unit tests of the templates do:
|
|
1388
|
+
|
|
1389
|
+
```bash
|
|
1390
|
+
npx local-storefront app function run volume-discount --input extensions/volume-discount/tests/fixtures
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
`--save-input` creates the folders of its file. When a file of the Function changed after its
|
|
1394
|
+
module was built, the run builds it first, as `shopify app function run` does.
|
|
1395
|
+
|
|
1396
|
+
## Test it
|
|
1397
|
+
|
|
1398
|
+
The checkout is part of the [control API](control-api.md). A test puts a session in a known
|
|
1399
|
+
state, then drives the checkout like the page does, and places the order. The checkout belongs to
|
|
1400
|
+
the session: Playwright's `request` keeps its cookie; a client without cookies names its session
|
|
1401
|
+
with an `x-lsf-session` header.
|
|
1402
|
+
|
|
1403
|
+
```ts
|
|
1404
|
+
await request.put('/__lsf/session', {
|
|
1405
|
+
data: { cart: { items: [{ variant: 'TEE-BLK-M', quantity: 3 }] } },
|
|
1406
|
+
});
|
|
1407
|
+
const checkout = await request.get('/checkout', { maxRedirects: 0 });
|
|
1408
|
+
const path = checkout.headers()['location']; // /checkouts/cn/<token>
|
|
1409
|
+
|
|
1410
|
+
const state = await (await request.get(`${path}.json`)).json();
|
|
1411
|
+
await request.post(`${path}/update`, {
|
|
1412
|
+
data: {
|
|
1413
|
+
operations: [
|
|
1414
|
+
{ type: 'email', value: 'buyer@example.com' },
|
|
1415
|
+
{ type: 'shippingAddress', address: { lastName: 'Buyer', address1: '1 Main St', city: 'New York', countryCode: 'US', provinceCode: 'NY', zip: '10001' } },
|
|
1416
|
+
{ type: 'paymentMethod', handle: 'card' },
|
|
1417
|
+
],
|
|
1418
|
+
},
|
|
1419
|
+
});
|
|
1420
|
+
const { order } = await (await request.post(`${path}/complete`)).json(); // { id, number, name }
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
The order is an order of the [Admin API](admin.md#place-an-order), and its `orders/create`
|
|
1424
|
+
reaches the backend of the app. A test that only needs an order (for its webhooks) serves the
|
|
1425
|
+
Functions it needs and no others, `--extension <handle>` repeated, or none, `--no-functions`: a
|
|
1426
|
+
Function that fails blocks `complete`.
|
|
1427
|
+
|
|
1428
|
+
| Request | What it does | Answer |
|
|
1429
|
+
|---|---|---|
|
|
1430
|
+
| `GET /checkout` | Leads to the checkout of the session's cart. `?variant=shop` for the other look, `?targets=all` for [every extension target](#every-extension-target). | A redirect to `/checkouts/cn/<token>` (`/fr/checkouts/cn/<token>` from `/fr/checkout`) |
|
|
1431
|
+
| `GET /checkout?scenario=<name>` | Opens a checkout for a [scenario](#scenarios), in a new session. | A redirect to its checkout with a new session cookie, or `404`/`422` and a page that says why |
|
|
1432
|
+
| `GET /checkout?customer=<email>` | Logs the session in as a [customer of the data](#log-in-as-a-customer) (`default`, `none` too), then leads to its checkout. With `scenario=`, opens the scenario as them. | A redirect, or `404` and a page that lists the customers |
|
|
1433
|
+
| `GET /` (`lsf app dev`) or `GET /__lsf/checkouts` | The [home page](#the-home-page). `?format=json` for what it says, `?check=1` to [run the Functions of the scenarios](#the-functions-that-fail) first. | HTML, or JSON |
|
|
1434
|
+
| `GET /__lsf/checkouts/check` | Runs the Functions of every scenario (or takes the last run on the same app and data): [the ones that fail](#the-functions-that-fail). | JSON: `failures` (`handle`, `target`, `error`, `logs`, `scenarios`), and what the page shows of it |
|
|
1435
|
+
| `GET /checkouts/cn/<token>` | The page. | HTML, with the state in it |
|
|
1436
|
+
| `GET /checkouts/cn/<token>.json` | The state of the checkout. | JSON |
|
|
1437
|
+
| `POST /checkouts/cn/<token>/update` | Applies `{"operations": [...]}`, in order. | `200` and the state, or `400` `{"error": "…"}` and nothing applied, or `409` and the state as it is now when the order was placed meanwhile or the session changed under it |
|
|
1438
|
+
| `POST /checkouts/cn/<token>/complete` | Places the order. | `200` `{"redirect": "…/thank-you", "order": {"id", "number", "name"}}`, the order as the Admin API recorded it: `number` is an integer (`1004`), `name` the string (`"#1004"`), `id` the `admin_graphql_api_id` of its `orders/create` (without the Admin API, `--only checkout`: `gid://shopify/Order/<number>`, the session's number). The state's `order.number` is a string, the session's, which can differ (below). Or `422` and the state with `errors`, or `409` and the fresh state when the cart changed meanwhile. Asked again once the order is placed: `200` and the same `redirect` and `order` |
|
|
1439
|
+
| `GET /checkouts/cn/<token>/thank-you` | The page of the order. | HTML |
|
|
1440
|
+
| `GET /__lsf/checkout?session=<id>` | The state of a session's checkout, from outside its browser. | JSON |
|
|
1441
|
+
|
|
1442
|
+
The token is the one of the session's cart. Another session gets a `404`.
|
|
1443
|
+
|
|
1444
|
+
### Language
|
|
1445
|
+
|
|
1446
|
+
The buyer reads the checkout in the language its URL names, as the storefront is: every path above also
|
|
1447
|
+
exists under a locale prefix (`/fr/checkout`, `/fr/checkouts/cn/<token>`,
|
|
1448
|
+
`/fr/checkouts/cn/<token>/update`, …). The cart form of a theme leads to `/fr/checkout` from the
|
|
1449
|
+
French storefront, the redirect keeps the prefix, and so do the page's own requests, the links
|
|
1450
|
+
back to the storefront and the way to the thank-you page. Without a prefix the checkout is in
|
|
1451
|
+
the primary language of the store. `/__lsf/checkout` has no prefix to read: it takes
|
|
1452
|
+
`?locale=fr`.
|
|
1453
|
+
|
|
1454
|
+
The state tells both languages apart: `shop.locale` is the primary language of the store, and
|
|
1455
|
+
`localization.language` the one the buyer reads. Extensions get the latter as
|
|
1456
|
+
`shopify.localization.language`, translate and format numbers and money with it. The Functions
|
|
1457
|
+
read it too, as `localization.language` of their input. The words of lsf's own pages (the
|
|
1458
|
+
checkout, the thank-you page, the customer account: "Delivery", "Pay now", "Orders") are not
|
|
1459
|
+
translated: they are English, and the document says so (`lang="en"`), whatever the language of
|
|
1460
|
+
the URL. A thank-you page is read in the language of its URL, not the one the order was
|
|
1461
|
+
placed in. A language that a session adds with `data.localization.languages` is a locale of that
|
|
1462
|
+
session's checkout.
|
|
1463
|
+
|
|
1464
|
+
### Buyer and localization
|
|
1465
|
+
|
|
1466
|
+
`buyer` is what `shopify.buyerIdentity` reads: the `email` and `phone` the buyer typed or the
|
|
1467
|
+
logged-in customer has, `customer` (`id`, `email`, `phone`, `firstName`, `lastName`,
|
|
1468
|
+
`acceptsMarketing`, `ordersCount`) and, for a B2B customer, `purchasingCompany` (the `company`
|
|
1469
|
+
and the `location` they buy for, each with `id`, `name` and `externalId`). `localization` is what
|
|
1470
|
+
`shopify.localization` reads: `language` and `rootUrl` as above, the `country` (the one of the
|
|
1471
|
+
shipping address, otherwise the one the visitor selected on the storefront), the `currency` and
|
|
1472
|
+
the `market` of that country. The store has no avatar, no consent to SMS marketing and no store
|
|
1473
|
+
credit for a customer, nor a time zone for a buyer (`timezone` is the one of the browser).
|
|
1474
|
+
|
|
1475
|
+
A buyer with no address and no selected country is in the shop's country
|
|
1476
|
+
(`shop.address.country_code`), the one of its primary market, as on Shopify. The Functions read
|
|
1477
|
+
it as `localization.country`, the extensions as `shopify.localization.country`, and the
|
|
1478
|
+
address form starts on it. Both read the same `market`. A shop in a country the store does not
|
|
1479
|
+
sell to uses the first country of the store. The storefront shows the same country to a visitor
|
|
1480
|
+
who selected none (Liquid's `localization.country`, `Shopify.country`), so the checkout opens in
|
|
1481
|
+
the country they browsed in.
|
|
1482
|
+
|
|
1483
|
+
The state has the lines, discounts, discount codes, delivery groups, payment methods (each with
|
|
1484
|
+
its `placements`), express buttons (`expressMethods`), errors and cost that the Functions
|
|
1485
|
+
produced, `runs` (a run of a `.fetch` target has its `exchange`, the request and the response),
|
|
1486
|
+
the buyer, the address, the note, the attributes and
|
|
1487
|
+
the metafields (the cart's), `appMetafields` (the metafields the extensions declare of the shop,
|
|
1488
|
+
the products and variants of the lines and the buyer), the `extensions` to load and
|
|
1489
|
+
`diagnostics`. Money is `{"amount": 19.99}`.
|
|
1490
|
+
|
|
1491
|
+
No shipping rate is selected until the address has a country, as on Shopify. Until then, the
|
|
1492
|
+
page shows "Enter shipping address", and no total has the shipping: `cost.shipping` is 0,
|
|
1493
|
+
`cost.total` and `cost.dueNow` leave it out, and so does `shopify.cost.totalAmount`.
|
|
1494
|
+
`shopify.cost.totalShippingAmount` is undefined. The Functions read `selectedDeliveryOption:
|
|
1495
|
+
null` and a `cart.cost.totalAmount` without shipping. The rates are still offered, so a delivery
|
|
1496
|
+
customization runs on them.
|
|
1497
|
+
|
|
1498
|
+
### Operations
|
|
1499
|
+
|
|
1500
|
+
| `type` | Fields | Does |
|
|
1501
|
+
|---|---|---|
|
|
1502
|
+
| `customer` | `customer`: an email, `default`, or `null` to log out; `location` (optional): a company location by id or name | Logs the checkout in as a [customer of the data](#log-in-as-a-customer). The session gets the customer; the contact and the address become theirs. |
|
|
1503
|
+
| `companyLocation` | `location`: a `gid://shopify/CompanyLocation/…`, its number or its name | Chooses the location the logged-in B2B customer buys for; the order ships there. |
|
|
1504
|
+
| `email`, `phone` | `value` | Sets the contact. An empty value clears it. |
|
|
1505
|
+
| `shippingAddress` | `address`: `firstName`, `lastName`, `company`, `address1`, `address2`, `city`, `countryCode`, `provinceCode`, `zip`, `phone` | Sets the address. A country of the store becomes the country of the session. A `countryCode` that is not a country (ISO 3166-1 alpha-2, or `XK`, `AC`, `TA` as Shopify takes them; not `ZZ`) is refused and changes nothing. The store data has no shipping zones: any real country is taken. |
|
|
1506
|
+
| `deliveryOption` | `group` (`group-1`), `handle` | Chooses a shipping rate. |
|
|
1507
|
+
| `paymentMethod` | `handle` | Chooses a payment method. No handle clears it. |
|
|
1508
|
+
| `discountCode` | `action` (`add` or `remove`), `code` | Enters or takes back a code. |
|
|
1509
|
+
| `cartLines` | `change`: `addCartLine`, `removeCartLine` or `updateCartLine` of the UI extension API | Changes the cart of the session, the one the theme shows. |
|
|
1510
|
+
| `attribute` | `change`: `updateAttribute` or `removeAttribute`, with `key` and `value` | Changes the attributes of the cart. |
|
|
1511
|
+
| `note` | `change`: `updateNote` (`note`) or `removeNote` | Changes the note of the cart. |
|
|
1512
|
+
| `metafield` | `change`: `updateMetafield`, `updateCartMetafield`, `removeMetafield` or `removeCartMetafield`, with `namespace`, `key`, `valueType`, `value` | Sets a metafield for the Functions to read. |
|
|
1513
|
+
|
|
1514
|
+
`complete` refuses an empty cart, an order with errors from a Function, and one without an
|
|
1515
|
+
email, a payment method or, for a cart that ships, an address with a last name, a first line and
|
|
1516
|
+
a city. It empties the cart: the order is what the thank-you page shows, and the state's `order`
|
|
1517
|
+
(`id`, `number`, `processedAt`, `totalReceived`, `totalOutstanding`) once `status` is `completed`.
|
|
1518
|
+
That `number` is a string, the session's (`"#1004"` every run). The `order` of `complete` is the
|
|
1519
|
+
Admin API's, and there `number` is an integer (`1004`) and `name` the string (`"#1004"`); they
|
|
1520
|
+
are `1005` and `"#1005"` when the shop has a `#1004` already. To match the two, use `id`, which
|
|
1521
|
+
both have; never compare the two `number`s. The state's `number` is a string since 0.2.0, and
|
|
1522
|
+
stays one. `complete` takes the cart only if the cart is still the one the Functions ran on: if
|
|
1523
|
+
it changed meanwhile (another tab), nothing is bought and the reply is `409`. Completing an order that was placed already leads to the same thank-you page, never to a
|
|
1524
|
+
second order: `200`, with the same `redirect` and `order`. An `update` that comes once the order
|
|
1525
|
+
is placed (a line changed in another tab, a request sent late) changes nothing either: the reply
|
|
1526
|
+
is `409` and the state as it is now, whose `status` is `completed`.
|
|
1527
|
+
|
|
1528
|
+
### In CI
|
|
1529
|
+
|
|
1530
|
+
Once, in the app's folder: `npm install --save-dev local-storefront`, then commit
|
|
1531
|
+
`package.json` and `package-lock.json`. In the job, `actions/setup-node` and `npm ci` come
|
|
1532
|
+
before these steps, as in [testing](testing.md#in-ci). `dist/` is not committed: build the
|
|
1533
|
+
extensions before checking them.
|
|
1534
|
+
|
|
1535
|
+
```yaml
|
|
1536
|
+
- run: npx local-storefront app build # every Function and UI extension, without Node
|
|
1537
|
+
- run: npx local-storefront validate # the store data, the scenarios and the app
|
|
1538
|
+
- run: npx local-storefront app check # every Function of every scenario runs, and each `expect` holds (`--build` builds first)
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
`app check` fails on a scenario that does not validate, on a Function that traps or panics,
|
|
1542
|
+
runs past 11 million instructions or answers an output out of its schema, and on an
|
|
1543
|
+
[expectation](#expected-results) of a scenario that is not met: a discount of the wrong amount,
|
|
1544
|
+
PayPal left in the express buttons. It also fails on an extension the checkout does not offer
|
|
1545
|
+
(an `api_version` other than `2026-10` and `2027-01`, no module or bundle), on a Function whose
|
|
1546
|
+
files changed after its module was built (`stale`, unless `--build`: its folder, and the app's
|
|
1547
|
+
files every build reads, such as a workspace's `Cargo.toml` or a shared package; a lock file
|
|
1548
|
+
the build writes itself does not count), and on what Shopify rejects in a theme app extension.
|
|
1549
|
+
`--extension` naming theme app extensions only checks those. A trap ends with the last message the Function logged
|
|
1550
|
+
(`the Function trapped: Error: boom (in b2b)` for a `throw new Error("boom")`), and the JSON has
|
|
1551
|
+
the `logs` of each failure. For what `expect` cannot say, `app function run --format json` has the
|
|
1552
|
+
whole checkout (`lines`, `discounts`, `deliveryGroups`, `paymentMethods`, `expressMethods`,
|
|
1553
|
+
`errors`, `cost`, and each run in `runs`) for `jq`, and a test can drive the checkout, as above.
|
|
1554
|
+
The browser tests of a theme in CI: [testing](testing.md#in-ci).
|
|
1555
|
+
|
|
1556
|
+
## What it does not do
|
|
1557
|
+
|
|
1558
|
+
- Payments, gift cards, and payment method attributes. Choosing a payment method pays nothing,
|
|
1559
|
+
and the balance of a [deferred payment](#deferred-payments) is never collected.
|
|
1560
|
+
- One delivery group holds every line that ships. No pickup points (their list stays empty), no
|
|
1561
|
+
split shipments, no prices by address: the rates are the same for every address.
|
|
1562
|
+
- Fulfillment constraints, order routing, and the pickup point and local pickup generators (with
|
|
1563
|
+
their `.fetch` targets) are listed and not run.
|
|
1564
|
+
- Discounts have no `conditions` and no combinations: all of them stack, as described in
|
|
1565
|
+
[how discounts stack](#how-discounts-stack). Shopify lets the merchant choose which discounts
|
|
1566
|
+
combine, and applies the best of those that cannot; here all the product and order discounts
|
|
1567
|
+
combine, and of the shipping discounts the best one applies.
|
|
1568
|
+
- Tax is one rate on the discounted subtotal, with no tax lines.
|
|
1569
|
+
- No Storefront API: `query` in an extension rejects. No Customer Account API either: a customer
|
|
1570
|
+
account extension reads the account through its `shopify` global only.
|
|
1571
|
+
- `shopify.appMetafields` has no `shopUser` entries (the store data has no staff accounts).
|
|
1572
|
+
- The customer account shows; it changes nothing (no address to edit, no order to cancel or
|
|
1573
|
+
return), and its intents (`shopify.intents.invoke`) are closed at once.
|
|
1574
|
+
- Some fields of the Function input hold what the store data says and nothing more. The orders of
|
|
1575
|
+
the customers are not counted in the `ordersCount` of a company location, nor spent in its
|
|
1576
|
+
`totalSpent` (set them in the data); a `Market` is a country, or the group of countries that
|
|
1577
|
+
`localization.markets` declares, with no catalog or price list; the `id` of a `Metaobject`
|
|
1578
|
+
is made up from its type and handle.
|
|
1579
|
+
- Shop Pay and express checkout: the express buttons show (the methods of the
|
|
1580
|
+
`ACCELERATED_CHECKOUT` placement that the payment customizations left), but a click only says
|
|
1581
|
+
that lsf does not simulate the wallet. Logging in is picking a customer of the data, see
|
|
1582
|
+
[log in as a customer](#log-in-as-a-customer).
|
|
1583
|
+
- With `app dev` (no theme), the links to the storefront (return to cart, continue shopping)
|
|
1584
|
+
lead to pages the server does not have. Use `serve --app` for those.
|
|
1585
|
+
- A scenario is one session per browser: opening another replaces it. The scenario cannot set
|
|
1586
|
+
what the theme shows outside the cart and the customer (`theme_settings`, a frozen clock).
|
|
1587
|
+
|
|
1588
|
+
## What to test on a real store
|
|
1589
|
+
|
|
1590
|
+
What lsf leaves out, or decides where Shopify does not document it. Test these on a development
|
|
1591
|
+
store before a release:
|
|
1592
|
+
|
|
1593
|
+
- **Discount combinations and conditions**: lsf combines every product and order discount
|
|
1594
|
+
([how discounts stack](#how-discounts-stack)).
|
|
1595
|
+
- **Currencies and price lists**: one price list, in the shop's currency; a buyer abroad pays it
|
|
1596
|
+
too ([what it does not do](#what-it-does-not-do), [markets](compatibility.md#known-differences)).
|
|
1597
|
+
- **B2B catalogs, pickup points, local pickup, fulfillment constraints and order routing**: not
|
|
1598
|
+
run.
|
|
1599
|
+
- **Network access**: the request JWT and Shopify's cache of responses
|
|
1600
|
+
([network access](#network-access)).
|
|
1601
|
+
- **The settings of an extension** (`shopify.settings`): those of the store data or the
|
|
1602
|
+
scenario here, not the checkout editor's.
|
|
1603
|
+
- **lsf's choices**: how several customizations chain and how `linesMerge` counts
|
|
1604
|
+
([what runs](#what-runs)), which validation step blocks the order
|
|
1605
|
+
([network access](#network-access)), the split between due today and due later
|
|
1606
|
+
([deferred payments](#deferred-payments)).
|
|
1607
|
+
- **Instruction counts near the 11 million limit**: lsf does not run `wasm-opt`, so a Rust
|
|
1608
|
+
Function costs 0.25 to 0.5% more here ([Rust Functions](#rust-functions)).
|
|
1609
|
+
- **Payments**, Shop Pay and express checkout.
|