Every page on an Appmint-hosted site loads one script, /runtime/appmint.js. It puts window.appmint on the page and scans the HTML for data-* attributes: it fetches what a data-source names, repeats rows, fills data-bind values, and wires data-action buttons to window.appmint methods. Most live pages need nothing else. Page JavaScript is for what attributes can't do, and it must be put in the right slot and started at the right time.
This page is the reference for that engine: the attributes, the expression rules, the timing, every window.appmint method with the endpoint it calls, and three worked examples tested on a real site. For how a page is stored and saved, see Build a website page with components. For shop and product pages, see Catalog pages from your products. For drawers, dialogs, tabs and sliders (wm-*), see Appmint components on a page. For the header, footer and root's site-wide script, see Shared header and footer.
Ask first
- Which page, and what should be live on it? Name each live part: a product list, a cart button, the customer's name, their orders, a booking picker. Each one maps to an attribute or a method below.
- Can attributes do it? Try
data-source/data-bind/data-actionfirst (step 2). Only write JavaScript for what's left: a quantity box, a multi-step picker, your own rendering. - Is the code for this page only, or for every page? One page: its own Page settings. Every page: root's Page settings, or a Scripts file linked from each page (step 1).
- Does it send anything to real people? A booking, a sign-up, a ticket or an order sends emails (and a booking can send texts). Agree how you will test before anything is submitted (Stop in step 5).
Steps
1. Decide where each piece goes
| What | Where | How it loads |
|---|---|---|
| Live values, lists, buttons that call the platform | data-* attributes in the page HTML (step 2) | Wired by the runtime, on every load and after the site's own in-app moves. No script of yours. |
| Code for one page | the page's Page settings → Style → JavaScript: the record's top-level style.javascript (step 3) | One inline <script>, after the page content, on a full load only |
| Code for every page | root's Page settings → Style → JavaScript (style.javascript on the page named root) | Same inline <script>, before the page's own code |
| A long or shared script file | a .js file (written in DAM › Scripts, or uploaded), its URL in the page's (or root's) style.scriptLinks (step 4) | <script src async>; runs whenever it arrives |
Code inside the page HTML (<script> in the head or body) | don't | Runs on a full load only, not after an in-app move, not inside root's screens, not with a data-react block |
How the site builds the scripts (base-app app/[[...slug]]/page-assets.ts:30-71 and [[...slug]]/layout.tsx:79-92):
- It walks root → parent page → the page → the site record. From each
styleit takesstyleLinks,css,scriptLinksandjavascript. - All the
javascriptstrings are joined with nothing between them, then the site's tracking code (Google tag, Meta pixel, cookies) is added, all in one<script>. A syntax error or an uncaught throw anywhere in it stops everything after it: root's code, your page's code and the tracking. - Each
scriptLinksURL becomes<script src="…" async>, placed before that inline script.asyncmeans no order: on the test site a linked file ran about 200 ms after the page's inline code. - A URL owned by a built-in feature (for example
/loginor/account) takes its scripts from the template page it borrows, not from a page of that name.
End every script with ; and wrap root's in try { … } catch (e) { console.error(e) }. Tested: root's code ends })(); and the page's starts (function boot() { with nothing in between. Without the ;, the second function would be called with the first one's return value.
Hands on: a list of the live parts and, for each, whether it's an attribute, page code, root code or a script file.
Check: nothing is planned as a <script> inside the page HTML or inside root's screens.
2. Build Studio: write the markup with the runtime's attributes
Write the page HTML the way Build a website page with components stores it (page_write with html). Put the live parts in with attributes:
<section id="grid" data-source="product.list"
data-source-payload='{"category":"{{$query.category}}","limit":24}'>
<article data-bind-each>
<h3 data-bind="data.name"></h3>
<p data-bind="data.price" data-format="currency"></p>
<button data-action="cart.add" data-payload="this">Add to cart</button>
</article>
</section>
<button data-action="cart.toggle">Cart <span data-bind="cart.count" data-format="parentheses"></span></button>data-source="ns.method"calls that method once with the parseddata-source-payload. An array result repeats eachdata-bind-eachchild once per row. Inside a row, paths read the row first (data.name,sk), then the source, then page state.data-bindreads page state anywhere:cart.count,site.title,query.ref,customer.current.data.email(full list under State you can bind below).data-actioncalls a method on click (submit for a form, change for an input), withdata-payloadas its one argument.
The full syntax is in the reference sections below: Attributes, Payloads and {{…}}, Expressions, Effects, Formats, Triggers.
Expressions can't call JavaScript globals. Every name in an expression is turned into a local and filled from the row or page state, so Number, Math, parseInt, JSON and Date are undefined and the element renders empty, with no error. Tested: Number(data.price) and Math.round(data.price) came out blank; data.price.toFixed(2) ("49.50"), .toLocaleString() and data.price|0 worked. Use data-format for money, dates and numbers (base-app src/runtime/dispatcher.ts:791-826).
Hands on: the page's id and its HTML, with every live part as an attribute.
Check: repo_find_one on the page: the attributes are in data.screens[0].html and data.html, unchanged (no this. inside {{…}}, no JS globals in expressions).
3. Page settings → Style: the page's JavaScript
Write it with page_write: { "id": "<sk>", "javascript": "…" }. It appends to what is there; to change it, pass the whole script with replaceStyle: true. Start every page script this way:
(function boot() {
// 1. Never on the Build Studio canvas: DOM built here would be saved into the page.
if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
// 2. Wait for your own element AND the runtime part you call.
var app = document.getElementById('my-app');
if (!app || !(window.appmint && window.appmint.cart)) return void setTimeout(boot, 30);
// 3. Run once, even if the script is in the page twice.
if (app.dataset.bound) return;
app.dataset.bound = '1';
// …your code: addEventListener, textContent for record values…
})();Why each line is there (tested on the local site):
| Moment | What exists |
|---|---|
| Your inline script runs | while the HTML is still loading (document.readyState is loading). The page's content and root's header and footer are already in the DOM above it. window.appmint is not there yet. |
| React hydrates the page | the page content sits inside a wrapper marked data-appmint-hydrating until then (page-client.tsx:132) |
/runtime/appmint.js runs | after hydration (<Script strategy="afterInteractive">, app/layout.tsx:122). It sets window.appmint, asks who the customer is, seeds site, page, query and path, mirrors the cart, and starts the attribute scanner (src/runtime/index.ts:315-340). |
| The scanner fills attributes | on start, then again for anything added to the page (a MutationObserver) and when the hydrating marker is removed. It never touches a subtree still marked data-appmint-hydrating (dispatcher.ts:64-115). |
- Wait for the shared chrome too, when you use it. A site whose root script sets up helpers waits for them: PhaZZer's page scripts wait for
document.getElementById('pzfoot')andwindow.PZ.booted, which root's script sets last. Wait for whatever root defines, not a fixed time. - Your inline script runs only on a full page load. When the site's own React screens move the visitor without a reload (for example the redirect back from
/login), the script tag is put in but not run. Tested: after signing in, the page's code did not run; itsdata-*attributes still worked. Anything that must work after sign-in belongs in attributes (worked example B). Plain<a href>links in your HTML do a full load. - The canvas. On Build Studio's edit canvas the body has
data-design-mode="true"and the runtime isn't loaded; the preview loads both the runtime and your page JavaScript (websitemintcomponents/common/iframe-portal/iframe-canvas.tsx:301-313,components/build-studio/full-html-doc.ts:157).
A data-action click never reaches your code. The runtime calls preventDefault() and stopPropagation() on every event it handles (dispatcher.ts:199-201). A listener on a parent never sees clicks on data-action elements, an <a href> with data-action doesn't follow its link, and a checkbox with data-trigger="click" won't tick. Give your own buttons no data-action, as the shop example does.
Hands on: the page's style.javascript.
Check: repo_find_one shows style.javascript at the top level (not data.style), it parses, and it starts with the design-mode guard.
4. DAM › Scripts: a script file, linked with style.scriptLinks
For a long script, or one several pages share, keep it as a file.
- DAM › Scripts (Studio route
/client-apps) → new script. Name: lowercase letters, digits,-or_, starting with a letter; it can't be renamed later. Write the code in the editor or upload a.js/.mjs. - Save. The editor uploads the code to
<org>/client-apps/<name>/<name>.jsand keeps aclient_apprecord (name,description,status,code,entryPoint= the file's URL,sourceFile,runtime: "appmint-js") (websitemintcomponents/configuration/client-apps/client-app-editor.tsx:128-170). The URL it shows isentryPoint. - Link it. Add that URL to the page's
style.scriptLinks(page_write{ "id": "<sk>", "scriptLinks": ["<url>"] }; it merges with links already there). For every page, put it in root'sscriptLinks.
An agent can skip the editor: save_page_asset with path: "client-apps/<name>/<name>.js" and content writes the same file publicly with application/javascript and returns its url (with a ?ts= cache-buster); or upload it with call_appengine POST repository/file/upload (multipart file, and location set to the folder). Then page_write the URL into scriptLinks.
Saving in Scripts doesn't put the script on any page. Nothing on the site reads the client_app record; a script loads only from a scriptLinks URL. The record's Status (Draft / Active / Disabled) changes nothing on the site: a Disabled script still loads wherever it is linked, and deleting it from the list leaves the file and the links. Build Studio's block panel reads active scripts but never offers them as blocks (components/preset/web/index.tsx:33-71).
- A linked file runs
async, in no fixed order, before or after your inline code and before or afterwindow.appmint. Start it with the sameboot()wait as step 3. Tested: the file ran at 747 ms, after the page's inline code (540 ms), andwindow.appmintwasn't there yet. - Re-saving keeps the same URL. Browsers and the storage CDN may serve the old copy; after a change, add
?v=2to the URL inscriptLinks. - Check the file is served as JavaScript.
curl -sI <url>should saycontent-type: application/javascript. The test upload did; a file served asapplication/octet-streamcan be refused. - The Scripts editor's API hints show
appmint.cart.add({ productId: '', quantity: 1 }). That adds a nameless $0 line: the cart readsid/sku,nameandunitPrice, neverproductId(client-apps/script-api-reference.ts:25, base-appsrc/runtime/namespaces.ts:122-153).
Which to use:
style.javascript (page) | root's style.javascript | Scripts file in scriptLinks | |
|---|---|---|---|
| Runs on | that page | every page | each page that links it |
| Order | after root's, before the site record's and the tracking | first | unordered (async) |
| A syntax error | stops root's, the page's and the tracking that come after it in the joined script | stops everything on every page | stops only that file |
| Edit | Page settings, or page_write (appends) | root's Page settings | the Scripts editor, or save_page_asset to the same path |
| Use for | that page's widget | menu, sticky header, helpers every page uses | long code, code shared by a few pages |
Hands on: the file's URL, and the pages whose scriptLinks now carry it.
Check: repo_find_one on each page: style.scriptLinks contains the URL. curl -sI on the URL answers 200 with a JavaScript content type.
5. Check it on the site
Open the page at https://<site host>/<slug> in a real browser (or headless Chrome), not the page source.
- Every
data-sourcehas rows ([data-each-rendered]elements), everydata-bindhas text, and no element carriesdata-error. - One click on each button does one thing: one cart line, one count change, one request.
- The browser console has no
[appmint]warnings (data-source failed,unknown effect) and no errors. - Sign in and out (the site's
/login), and check the parts that depend on the customer. - Open the page in Build Studio: the canvas shows the page, and your script hasn't added anything to it.
Stop: don't submit a real booking, sign-up, ticket or order to test on a live site: each one sends real emails, and a booking can also text the customer. Show the person the page, then test submission with them, with their own address, or stop at the step before submit.
Worked examples
All three were run on a local copy of a real site (PhaZZer), rendered in headless Chrome and checked.
A. Product grid with a quantity box and add-to-cart (window.appmint.cart)
Markup (the page HTML):
<main id="sj-shop">
<button id="sj-cart" data-action="cart.toggle">Cart <span data-bind="cart.count" data-format="parentheses"></span></button>
<section id="sj-grid" data-source="product.list"
data-source-payload='{"category":"{{$query.category}}","limit":24,"sort":"name","sortType":1}'>
<article class="sj-card" data-bind-each>
<a data-bind-attr-href="'/product?slug=' + data.slug">
<img data-bind-attr-src="data.image" data-bind-attr-alt="data.name">
<h3 data-bind="data.name"></h3>
</a>
<p data-bind="data.price" data-format="currency"></p>
<label>Qty <input class="sj-qty" type="number" min="1" value="1"></label>
<button class="sj-add" type="button"
data-bind-attr-data-id="sk" data-bind-attr-data-sku="data.sku"
data-bind-attr-data-name="data.name" data-bind-attr-data-price="data.price"
data-bind-attr-data-image="data.image">Add to cart</button>
</article>
</section>
<p><span data-bind="catalogTotal"></span> products</p>
<ul data-loop="cart.items">
<li data-bind-each><span data-bind="name"></span> × <span data-bind="quantity"></span></li>
</ul>
<p>Total <span data-bind="cart.total" data-format="currency"></span></p>
</main>Page JavaScript (Page settings → Style):
(function boot() {
if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
var app = document.getElementById('sj-shop');
if (!app || !(window.appmint && window.appmint.cart)) return void setTimeout(boot, 30);
if (app.dataset.bound) return;
app.dataset.bound = '1';
// One listener for every card, including cards the runtime adds later.
app.addEventListener('click', function (e) {
var btn = e.target.closest('.sj-add'); if (!btn) return;
var qty = parseInt(btn.closest('.sj-card').querySelector('.sj-qty').value, 10) || 1;
btn.disabled = true;
window.appmint.cart.add({
id: btn.dataset.id, sku: btn.dataset.sku, name: btn.dataset.name,
unitPrice: Number(btn.dataset.price), quantity: qty, image: btn.dataset.image
})
.then(function (cart) { btn.textContent = 'Added (' + cart.itemCount + ' in cart)'; window.appmint.cart.open(); })
.catch(function (err) { btn.textContent = 'Could not add: ' + (err && err.message); })
.then(function () { btn.disabled = false; });
});
})();- The runtime renders the cards, and
data-bind-attr-data-*copies each product's fields onto its button, so page code reads them fromdatasetwith no lookups. cart.addputs the line in the site's cart, prices the cart on the server (POST storefront/pricing/calculate-cart), and resolves with the cart summary.- Tested: two cards with links, images and prices; quantity 3 on a $49.50 product gave count
(3), total $148.50, the line "… × 3", the cart drawer open, and the cart was still there on the next page. - For quantity 1 with no code at all,
<button data-action="cart.add" data-payload="this">inside the card does the same.
B. The customer's name and "My orders" (attributes only)
<main id="acct-app">
<!-- Re-read the customer once the page is live; then reload the orders. -->
<span hidden data-action="customer.current" data-trigger="load" data-on-success="refresh:#acct-orders"></span>
<section data-hide="customer.current.data.email">
<p>Sign in to see your orders.</p>
<a href="/login?redirect=/my-orders">Sign in</a>
</section>
<section data-show="customer.current.data.email">
<h1>Hello, <span data-bind="customer.current.data.firstName || customer.current.data.email"></span></h1>
<button data-action="customer.signOut" data-on-success="reload">Sign out</button>
<ul id="acct-orders" data-source="order.list" data-source-payload='{"limit":20}'>
<li data-bind-each>
#<span data-bind="data.number"></span>
<span data-bind="createdate" data-format="date"></span>
<span data-bind="data.status" data-format="capitalize"></span>
<span data-bind="data.amount" data-format="currency"></span>
</li>
</ul>
<p id="acct-empty" hidden>No orders yet.</p>
</section>
</main>Optional page JavaScript, for a count and the empty message, using the runtime's events:
(function boot() {
if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
var list = document.getElementById('acct-orders');
if (!list || !(window.appmint && window.appmint.order)) return void setTimeout(boot, 30);
if (list.dataset.bound) return;
list.dataset.bound = '1';
list.addEventListener('appmint:source-loaded', function (e) {
var rows = Array.isArray(e.detail.data) ? e.detail.data : [];
document.getElementById('acct-empty').hidden = rows.length > 0;
});
list.addEventListener('appmint:source-error', function () { // 403 when signed out
document.getElementById('acct-empty').hidden = true;
});
})();- Bind
customer.current.data.*. For a signed-in visitorcustomer.currentis the customer record ({ sk, data: { email, firstName, … } }), not flattened. For everyone else it is a plain string ("app@demo"on the test site), socustomer.current.data.emailis empty anddata-hideshows the sign-in block. - The
loadaction is what makes it work after sign-in. The sign-in page sends the visitor back without a reload, so page JavaScript doesn't run. The hiddencustomer.currentaction does, and itsrefresh:#acct-ordersfetches the orders again with the new sign-in. order.listreadsclient-data/orders(the signed-in customer's own). Signed out it answers 403 "Customer identity is required", and the list stays empty.createdateis on the record, not underdata.- Tested: signed out, the sign-in block; after
/login, "Hello, Skill", one row "#SKJS00001 10/20/2026 Paid $169.50"; Sign out went back to the signed-out block. - The runtime's first fetch can land before page code has attached its listener. Here the
refreshaftercustomer.currentfetches again, so the event comes; on a page without it, read the rows already rendered as well.
C. A booking widget (window.appmint.reservation)
The booking type (a reservation_definition) is set up first: see Take bookings with reservations and Bookable resources for a booking type. Two fields decide whether slots come back:
| Field | Rule |
|---|---|
data.workDays | Full English names, capitalised: ["Monday","Tuesday",…]. The server compares exactly; with "monday" every day is closed and slots answer { slots: [], closed: true, error: "We are closed on this day…" } (appengine crm/reservations.service.ts:698-709, util/helpers.ts:160). Tested both ways. |
data.services[] | Each { name, duration, price }. The booking's service is matched against a service's name or id, trimmed, any case (reservations.service.ts:1349-1360), so a separate id is no longer needed. |
data.officeHours.timezone | Slots are worked out in it; show times in it too |
Markup:
<main id="bk-app" data-definition="fitting">
<label>Service <select id="bk-service"></select></label>
<label>Date <input id="bk-date" type="date"></label>
<div id="bk-slots"></div>
<p id="bk-msg"></p>
<form id="bk-form" hidden>
<input name="name" placeholder="Your name" required>
<input name="email" type="email" placeholder="Email" required>
<input name="phone" placeholder="Phone">
<button id="bk-submit">Book</button>
</form>
<p id="bk-done" hidden>Booked. Your confirmation number is <strong id="bk-number"></strong>.</p>
</main>Page JavaScript:
(function boot() {
if (document.body && document.body.getAttribute('data-design-mode') === 'true') return;
var app = document.getElementById('bk-app');
if (!app || !(window.appmint && window.appmint.reservation)) return void setTimeout(boot, 30);
if (app.dataset.bound) return;
app.dataset.bound = '1';
var R = window.appmint.reservation;
var $ = function (id) { return document.getElementById(id); };
var def = null, picked = null;
var say = function (t) { $('bk-msg').textContent = t || ''; };
var tz = function () { return (def.data.officeHours && def.data.officeHours.timezone) || undefined; };
var timeLabel = function (iso) {
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZone: tz() });
};
// 1. The booking type named on the page.
R.definitions().then(function (rows) {
def = rows.filter(function (d) { return d.data && d.data.name === app.dataset.definition; })[0];
if (!def) return say('This booking type is not available.');
(def.data.services || []).forEach(function (s) {
var o = document.createElement('option');
o.value = s.name; o.textContent = s.name + (s.duration ? ' (' + s.duration + ' min)' : '');
$('bk-service').appendChild(o);
});
$('bk-date').value = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
loadSlots();
}).catch(function (e) { say('Could not load booking types: ' + e.message); });
// 2. Open slots for the service and date.
function loadSlots() {
picked = null; $('bk-form').hidden = true; $('bk-slots').textContent = ''; say('Loading times...');
R.availableSlots({ reservationDefinitionId: def.sk, serviceName: $('bk-service').value, serviceDate: $('bk-date').value })
.then(function (res) {
var slots = (res && res.slots) || [];
if (res && res.closed) return say(res.error || 'Closed on this day.');
if (!slots.length) return say('No times left on this day.');
say('');
slots.forEach(function (s) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'bk-slot'; b.textContent = timeLabel(s.startTime);
b.addEventListener('click', function () { picked = s; $('bk-form').hidden = false; });
$('bk-slots').appendChild(b);
});
})
.catch(function (e) { say('Could not load times: ' + e.message); });
}
$('bk-service').addEventListener('change', loadSlots);
$('bk-date').addEventListener('change', loadSlots);
// 3. Book the picked slot.
$('bk-form').addEventListener('submit', function (e) {
e.preventDefault();
if (!picked) return say('Pick a time first.');
var f = e.target, btn = $('bk-submit');
btn.disabled = true; say('Booking...');
// A visitor books without signing in through crm/reservations/book (reservation.create is the signed-in customer's own).
fetch('/api/crm/reservations/book', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reservationDefinitionId: def.sk, service: $('bk-service').value,
startTime: picked.startTime, endTime: picked.endTime,
customerName: f.name.value, customerEmail: f.email.value, customerPhone: f.phone.value
})
}).then(function (r) {
return r.json().then(function (j) {
if (!r.ok) throw new Error((j && j.error && j.error.error) || 'Booking failed');
return j;
});
}).then(function (rec) {
$('bk-number').textContent = (rec && rec.data && rec.data.name) || '';
f.hidden = true; $('bk-slots').textContent = ''; say(''); $('bk-done').hidden = false;
}).catch(function (err) {
say('Could not book: ' + err.message);
}).then(function () { btn.disabled = false; });
});
})();reservation.definitions()→GET crm/reservations/definitions, open to visitors; rows are{ sk, data: { name, title, services, workDays, officeHours, … } }.reservation.availableSlots({ reservationDefinitionId, serviceName, serviceDate })→POST client-data/reservations/available-slots.serviceDateisYYYY-MM-DDin the business's timezone (date,appointmentDate,startDateanddayare accepted too). It answers{ reservationDefinition, service, location, slots: [{ startTime, endTime, spotsAvailable, businessTimezone }] }.- The booking is
POST /api/crm/reservations/book, the call the site's own booking page makes, open to visitors. It takes flatcustomerName/customerEmail/customerPhoneor a nestedcustomer: { name, email, phone }(a name and an email or phone are required: 400contact_required);endTimedefaults to the service's length. It answers the new reservation;data.nameis the confirmation number. One contact can book 3 times an hour from the site (429too_many_bookings) (reservations.controller.tsbookFromSite).reservation.create(…)(POST client-data/reservations) takes the same body but only for a signed-in customer: a visitor gets 401 "Not signed in". The runtime has nobookmethod, hence thefetch. - Tested: the service list filled, 16 half-hour slots from 9:00 AM (Chicago) for the next day, and picking 10:00 AM posted
{"reservationDefinitionId":"…","service":"Fitting","startTime":"2026-10-21T15:00:00.000Z","endTime":"2026-10-21T15:30:00.000Z","customerName":…}. The booking call was intercepted in the browser and answered with a fake record, because a real one emails the customer and the business and can text the customer (reservations.service.ts:1563-1626). That test ran againstreservation.create; thebookcall above takes the same body and is read from the code, not tested.
Reference: attributes the runtime reads
| Attribute | On | What it does |
|---|---|---|
data-source="ns.method" | a container | Calls the method once with data-source-payload. An array result: each data-bind-each template is cloned once per row. One object: the container's descendants bind to it. Re-fetches when a state key named in the payload changes, or on refresh:#id. (dispatcher.ts:505-644) |
data-source-payload='{…}' | with data-source | JSON after {{…}} is filled in; not valid JSON → passed as a string |
data-loop="path" | a container | Like data-source, but the rows are an array already in state or in the row (cart.items, data.options). Re-renders when that top-level state key changes. (dispatcher.ts:128-181) |
data-bind-each (or data-repeat="true") | a child | The row template. Hidden until rendered; clones get data-each-rendered. Several sibling templates are cloned in order per row. |
data-bind="expr" | any | Sets textContent (replaces the element's children) |
data-bind-html="expr" | any | Sets innerHTML, not sanitized. BlockNote JSON is turned into HTML. |
data-bind-attr-<name>="expr" | any | Sets the attribute; null, undefined or false removes it. data-bind-attr-data-id sets data-id. |
data-bind-style-<prop>="expr" | any | Sets that style (data-bind-style-color, data-bind-style-background-color). Use the dashed CSS name: HTML lowercases attribute names, so …-backgroundColor arrives as backgroundcolor and sets nothing. |
data-bind-class="expr" | any | A string or array replaces className; an object { "on": cond } adds and removes only those classes |
data-show="expr" / data-hide="expr" | any | Sets style.display to '' or none. Falsy: null, false, 0, '', []. |
data-format="…" | with data-bind | See Formats |
data-action="ns.method" | any | Calls the method with the payload (see Triggers for when) |
data-payload | with data-action | See Payloads |
data-on-success / data-on-error | with data-action | Effects, joined with | |
data-trigger | with data-action | The event; see Triggers |
data-confirm="text {{…}}" | with data-action | window.confirm first; Cancel stops it |
data-no-validate | with data-action in a form | Skips the HTML5 checkValidity() gate |
data-media-target="#id" | with media.* | Names the <audio> / <video> |
data-form-store="<id>" | a <form> | form.* reads an @appmint/form store instead of the fields |
<script type="appmint/blocknote-block" data-type="x"> | head | Its body returns a renderer for BlockNote block type x (dispatcher.ts:679-702) |
data-showdoesn't removehidden. It only setsstyle.display, so<p hidden data-show="…">never shows. Don't puthiddenon adata-showelement.- Rendered rows go after everything else in the container. On every render the old rows are removed and the new ones appended at the end, after any static children. Keep a source container for rows only, and put headings and counts outside it.
- Other elements inside an array source don't see it. Tested:
<span data-bind="length">beside the rows stayed empty. BindcatalogTotal, or set state from theappmint:source-loadedevent. data-bind-class="'pill ' + x"(a string) wipes the element's own classes. Use the object form to keep them.data-loopover plain strings or numbers renders empty rows. Tested:data-bind="this"on a row of["a","b"]showed nothing. Loop over objects ([{ "label": "a" }]).data-erroris set when a source fails and not removed when a later fetch succeeds.
Reference: payloads and {{…}}
data-payload resolves to one value, the method's only argument (dispatcher.ts:364-405):
data-payload | Passes |
|---|---|
| (none) | a <form>: its fields as an object; an <input>/<select>/<textarea>: its value (a number for number/range, true/false for a checkbox); inside a row: the row; else undefined |
form | the containing form's fields (repeated names become arrays) |
this | the row inside data-bind-each, or the record inside a single-object data-source (tested with product.get); else the element's value |
this.value, this.checked, … | that property of the element, not the row |
'{"id":"{{sk}}","quantity":2}' | JSON, after {{…}} is filled in |
'"storefront/config"' | a JSON string |
{{data.sku}} or data.sku | the value at that path, its own type |
Inside {{…}} (dispatcher.ts:871-924): a path (row, then source, then state; in an effect, the action's result first), $query.<name> (URL parameter), $path.<n> (path segment, counted from 1), $json:<path> (JSON-encoded, with its own quotes), $json:this, $json:$result, $result (the result as text) and this (the row as text).
- Paths are bare:
{{sk}},{{data.name}}, never{{this.sk}}. Tested:{"id":"{{this.sk}}","name":"{{this.data.name}}","unitPrice":1}added a nameless $1 line with an empty id, and reported success. - Text into JSON goes through
$json:."name":"{{data.name}}"broke on a product calledSkilltest JS "Pro" Kit: the JSON didn't parse, the payload fell back toundefined, and the click failed with "Cannot read properties of undefined (reading 'cartItemId')"."name":{{$json:data.name}}(no quotes around it) worked for both products. - Numbers go in unquoted (
"unitPrice":{{data.price}}). If the value is empty the JSON breaks the same way. {{$path.1}}is the first path segment; the bindable statepath.0is also the first. They count differently.{{this}}in a string gives[object Object]. Use{{$json:this}}.
Reference: expressions
A data-bind, data-show, data-hide, data-bind-* value is a plain path (data.name, cart.count) or a JavaScript expression (data.stock > 0 ? 'In stock' : 'Sold out'). {{…}} inside it is filled in first (dispatcher.ts:791-826).
- Names come from the row, then the source, then page state. Every identifier becomes a function parameter filled that way. That is why globals (
Number,Math,JSON,Date,window,document) areundefined. - Methods on values work:
data.price.toFixed(2),data.name.toUpperCase(),(data.tags || []).join(', '),.toLocaleString(). - A word in a string literal counts as a name.
withorenumanywhere in an expression, even inside quotes, makes it invalid, and the binding goes blank. Tested:'enum'blanked it; other words were fine. thisin an expression is the row.- Re-rendering: each binding re-runs when a top-level state key it names changes (
cartforcart.count). Values inside a row change only when its source fetches again. - Errors inside an expression give
undefined(empty), never an exception.
Reference: effects
data-on-success="a | b". The text after : can use {{…}}, read from the action's result first (dispatcher.ts:436-484).
| Effect | Does |
|---|---|
navigate:/path?x={{sk}} | window.location.href = … (a full load) |
reload | reloads the page |
refresh:#id | fetches that element's data-source (or re-renders its data-loop) again |
set-state:key=value | true / false / null / numbers are typed, anything else is text. set-state:count={{itemCount}} after cart.add gives the new count. |
reset-form | resets the closest form |
focus:#id | focuses that element |
close-dialog | removes open from the closest open wm-dialog / wm-drawer |
open-dialog:#id | sets open on it |
notify:text / notify-error:text | fires a window event appmint:notify |
With no data-on-error, a failure runs notify-error:<message>. {{message}} in data-on-error is the error message.
notifyandnotify-errorshow nothing. Nothing on the site listens forappmint:notify. Tested: no toast. Show messages withset-state:msg=Savedand<p data-show="msg" data-bind="msg">. (cart.addshows its own "Item added to cart" toast.)- A bare
refreshdoes nothing. It fires onwindow; sources listen on their own element. Always name the target:refresh:#list. {{$result.x}}is empty. Write{{x}}: effect paths already read the result first. (The runtime's manifest shows{{$result.id}}; it doesn't work.)open-dialog/close-dialogdon't really open or closewm-dialog/wm-drawer: useui.dialog/ui.drawer(see Appmint components on a page).
Reference: formats
data-format on a data-bind element (dispatcher.ts:948-967). null / undefined → empty.
| Format | Gives |
|---|---|
currency | $49.50; another currency with data-format-opt-currency="EUR" (€49.50) |
number | 1,234.5 |
percent | 0.25 → 25% |
date / datetime | the browser's local date / date and time |
parentheses | 3 → (3); 0 → empty (for cart counts) |
uppercase / lowercase | |
capitalize | first letter up, the rest lower: "Skilltest JS Lantern" → "Skilltest js lantern" |
Unknown formats print the value as it is.
Reference: triggers
data-trigger | Fires |
|---|---|
| (none) | submit on a form, change on an input/select/textarea, click on anything else |
| any DOM event name | that event (input, mouseenter, keyup, …) |
load | once, as soon as the element is wired |
inview / visible | once, when 25% of it is on screen |
hovernever fires. It is not a DOM event, though the runtime's manifest lists it. Usemouseenter.loadruns before the page's own state is in. Tested:data-action="activity.view" data-payload='{"datatype":"page","id":"{{page.sk}}"}' data-trigger="load"calledstats/page/viewwith no id (404). Put the id in the HTML, or call it from page code onceappmint.state.get('page')is set.- A
loadaction inside a row runs again each time the rows render. - A form submit, or a button in a form, first runs the browser's validation; an invalid form stops the action and shows the browser's message.
Reference: events and markers
| Event / attribute | Where | When |
|---|---|---|
appmint:source-loaded | the data-source element | the fetch answered; e.detail = { data, action } |
appmint:source-error | the data-source element | the fetch failed; e.detail = { error, action } (the error has code, status, message) |
appmint:refresh | dispatch it on a data-source / data-loop element | fetch / render it again (what refresh:#id does) |
appmint:notify | window | notify effects; nothing listens |
data-loading | the action / source element | while the call runs |
data-error="message" | the source element | after a failed fetch |
data-each-rendered | each rendered row | always |
data-appmint-hydrating | the page wrapper | until React has hydrated; the runtime ignores what is inside |
Page code can also watch state: appmint.state.subscribe('cart', fn) (fires on cart and anything under it).
Reference: state you can bind
| Path | Holds | Set by |
|---|---|---|
site.title, site.name, site.<field>, site.sk | the site record, flattened (site.title, not site.data.title) | runtime start (index.ts:277-313) |
company.name, company.<field> | the org's company record, flattened | from the site |
page.name, page.title, page.slug, page.sk | the page record, flattened | runtime start (a request, so not there at first) |
query.<name>, query | URL parameters | start and every history change |
path.0, path.1, path.last, path.value, path.length | path segments (from 0), the full path | start and every history change |
cart.count, cart.total, cart.items, cart.isOpen | the site's cart; total is the server's figure | the cart mirror (index.ts:168-191) |
customer.current | signed in: the customer record, { sk, data: { email, firstName, lastName, … } }; otherwise a plain string | customer.current() |
customer.signedIn | true for everyone (see warning) | customer.current() |
catalogTotal, catalogPriceRange.minPrice / .maxPrice | from the last product.list | product.list |
| anything | your own | set-state:, appmint.state.set() |
customer.signedIn and appmint.customer.isSignedIn() are true for visitors who aren't signed in. The site's API proxy answers user/whoami for a visitor with its own app identity ("app@demo" on the test site), and the runtime stores that as the customer (namespaces.ts:696-708). Test customer.current.data.email instead.
Reference: window.appmint
Every call goes through the site's own /api/<path> proxy (the host's API client, with the org, the app token and the visitor's sign-in cookie), so pages never hold a token. A call made before the site's React layer is up falls back to a direct request (transport.ts:99-111); waiting for window.appmint as in step 3 avoids that in practice. A failed call rejects with an AppmintError: code is unauthenticated (401), forbidden (403), not-found (404), conflict (409), validation (422), rate-limited (429), server (5xx) or network, plus status and details (src/runtime/transport.ts:85-97,180-188). message is often the generic "Request failed with status code 403"; the server's message is in details.
Every method takes one argument when called from data-action (the payload). "List" methods return the row array (they strip { data: [...] }); the others return the server's answer as it is. ✓ = tested on the local site.
cart: the site's cart (local, server-priced) (namespaces.ts:350-461)
| Method | Does | Returns |
|---|---|---|
add(item) ✓ | Adds a line. Takes a cart line { id, sku, name, unitPrice, quantity, image, attributes?, description? } or a product record ({ sk, data: { sku, name, price, image } }, what this gives). Lines with the same id, SKU and chosen attributes merge. Then recalc(). | the summary below |
update({ id, quantity }) / update(id, qty) ✓ | id is the line's cartItemId (or a product id / SKU); quantity a number, "increment" or "decrement" | the summary |
remove(id) / remove({ id }) ✓ | removes the line | the summary |
clear() ✓ | empties the cart and its pricing | nothing |
current() ✓ | { id: '', items, subtotal, total, tax, shipping, discount, itemCount } | |
recalc() | POST storefront/pricing/calculate-cart with the lines, coupon and shipping address; folds the server's prices back | nothing |
count() / total() | a number | |
toggle() ✓ / open() ✓ / close() | the site's cart drawer | nothing |
A line's id is cartItemId, product-<id>-<sku>-<chosen attributes>. In a data-loop="cart.items" row, change quantity with data-action="cart.update" data-payload='{"id":"{{cartItemId}}","quantity":"increment"}' (tested). Never pass the product's own attributes list (the option schema) as a line's attributes; the runtime drops it. The cart is kept in the browser (cart-storage), so it survives page loads. With Storefront (and Rental) off, the cart drawer isn't on the page and toggle does nothing.
product: the storefront catalog (namespaces.ts:510-537)
| Method | Calls | Returns |
|---|---|---|
list(opts) ✓ | GET storefront/products. limit/pageSize → ps, page → p, sort → s, sortType → st, lastItem → l; category/categories, tag/tags, brand, keyword, price ("10-50"), minPrice, maxPrice pass through; any other key filters on a product attribute. Empty values are dropped. | product records; sets catalogTotal and catalogPriceRange |
get(id | { id | sku | slug }) ✓ | GET storefront/product/<id> | one product. An unknown slug came back as success, not an error. |
related(id | { id, limit }) ✓ | GET storefront/product/<id>/related | products |
categories() ✓ | GET storefront/categories | rows (empty on the test site) |
brands(name?), attributes(name?), collections(name?) | GET storefront/brands/…, storefront/attributes/…, storefront/collections/… | the server's answer |
A product row has sk and data.{ name, slug, sku, price, image, images, description, categories, tags, attributes }, and data.calculatedPrice as an object { originalPrice, finalPrice, discount, discountPercent, appliedDiscounts } (bind data.calculatedPrice.finalPrice, not the object). Keyword search works ("keyword":"{{kw}}", fetches again when kw changes; tested).
order: the signed-in customer's orders (namespaces.ts:542-560)
| Method | Calls | Returns |
|---|---|---|
list(opts?) ✓ | GET client-data/orders | order records; 403 when not signed in |
get(number) ✓ | GET client-data/orders/<number> | one order |
myOrders() ✓ | GET client-data/orders | the whole answer ({ data: [...], … }), not an array |
getByNumber(number) | GET storefront/order/get/<user>/<number> (signed in) | one order |
guestLookup({ email, orderNumber }) | GET storefront/order/email/<email>/<number> | one order |
cancel(number, reason) | POST storefront/order/cancel/<number> { reason } | the order. Only the signed-in customer's own order (401 signed out, 403 someone else's) |
refund(number, input) | POST storefront/order/refund/<number> | staff only: a customer gets 403 "A refund is made by the business. Ask the store to refund this order." Don't put it on a site page |
An order row: createdate, data.{ number, status, amount, items, … }.
customer: sign-in and the customer's own data (namespaces.ts:679-790)
| Method | Calls | Returns |
|---|---|---|
current() ✓ | the sign-in cookie's user; if none, GET user/whoami | the customer record, or the proxy's app identity string. Stored in customer.current. |
isSignedIn() ✓ | true even when not signed in (see warning above) | |
signIn({ email, password }) | the site's login | fails for visitors who aren't signed in ("Cannot read properties of null (reading 'orgId')", no request sent). Link to /login?redirect=<page>. |
signUp(input) | the site's registration | not tested (sends a welcome message); see Registration that creates the customer |
signOut() ✓ | clears the sign-in, POST user/signout | nothing; pair with data-on-success="reload" |
update(patch) | POST user/customer/update | the customer |
changePassword(input), resetPassword({ email }), subscribe(input), unsubscribe(input) | POST user/customer/password/change, the site's reset email, user/customer/subscribe, …/unsubscribe | nothing |
dashboard() ✓, profile() ✓, fullData(), analytics(), transactions(opts) | GET client-data/dashboard, /profile, /all, /analytics, /transactions | the server's answer (signed in) |
updateProfile(patch) | PUT client-data/profile | the server's answer |
addresses(), addAddress(a), removeAddress(id), autocompleteAddress(input), resolveAddressPlace(placeId) | client-data/addresses… | |
paymentMethods(), addPaymentMethod(input), removePaymentMethod(id), setDefaultPaymentMethod(id) | client-data/payment-methods… | |
wishlist(), wishlistAdd(productId), wishlistRemove(productId) | client-data/wishlist… | |
notifications(opts), registerPushToken(input) | client-data/notifications… | |
verificationStatus(), sendEmailVerification(), verifyEmail(input), sendPhoneVerification(input), verifyPhone(input) | client-data/verification/… (the send ones send real email / SMS) | |
messages(opts), conversations(), sendMessage(input), setMessageStatus(id, status) | client-data/messages…, client-data/conversations | The signed-in customer's own messages and threads only (401 signed out). sendMessage writes to the business, from the customer, as plain text: it can't choose another recipient. setMessageStatus takes read, unread, archived or deleted (crm.service.ts) |
benefits(), enrollBenefit(input), benefitEnrollments() | client-data/benefits… |
reservation: bookings (namespaces.ts:814-833)
| Method | Calls | Returns |
|---|---|---|
definitions() ✓ | GET crm/reservations/definitions (open to visitors) | booking-type records |
availableSlots({ reservationDefinitionId, serviceName, serviceDate }) ✓ | POST client-data/reservations/available-slots | { slots: [...], … } or { slots: [], closed: true, error } |
create(input) ✓ (intercepted) | POST client-data/reservations | the reservation; data.name = confirmation number. Sends emails and can text. Needs a signed-in customer (401 "Not signed in"); a visitor books through the site's booking page (POST crm/reservations/book) |
list() ✓, get(id) | GET client-data/reservations[/<id>] | the customer's own |
modify(input) | PUT client-data/reservations | the reservation |
cancel(id) | DELETE client-data/reservations/<id> | nothing |
guestLookup({ email, reservationNumber? }), guestCancel({ email, reservationNumber }) | GET crm/reservations/by-email/…, DELETE crm/reservations/cancel/… | Without reservationNumber, only for the signed-in customer's own email or phone: anyone else gets 400 reservation_number_required |
storefront: checkout, pricing, discounts, shipping (namespaces.ts:580-641)
| Method | Calls |
|---|---|
paymentGateways() | GET storefront/payment-gateways |
checkout(input), buyNow(input), checkoutMixed(input) | POST storefront/checkout-cart, /checkout-buy-now, /checkout-mixed (the visitor's landing campaign is added as attribution) |
takePayment(input) | POST storefront/take-payment |
subscriptions({ id? }), subscribe(input), subscription(id), updateSubscription({ id, action, … }) | storefront/subscriptions/… |
stripeIntent(input), stripeCheckout(input), paypalConfig(), verifyPayment({ provider, configId, paymentId }) | storefront/stripe/intent, /stripe/checkout-session, /paypal/config, /verify-payment/… (a visitor gets { ok, status } only; staff get the gateway's record) |
config() | GET storefront/config (404 on the test engine) |
pricing(input), cartPricing(input) | POST storefront/pricing/calculate, /calculate-cart |
taxCalculate(input), taxRate(input), taxExempt(input) | POST storefront/tax/calculate, /rate, /check-exempt (the signed-in customer's own status only; signed out it answers exempt: false) |
applyCoupon, validateCoupon, automaticDiscounts, calculateDiscounts, promotionPrice (input); activePromotions() | storefront/discounts/apply, /validate, /automatic, /calculate, /promotions/price, GET …/promotions/active |
shippingRates(input), shippingMethods(), shippingCost(input), shippingCalculate(input), verifyAddress(input) | POST shipping/rates, GET shipping/methods, POST shipping/product-cost, /calculate, /verify-address |
saveCart(input) | POST storefront/cart/save |
productSearch(input), productBrands(), productCategories(), productCollections() | POST storefront/product/search, GET storefront/brands/, /categories, /collections/ |
Payments and the checkout pages: see Take payments on the site. Don't place a real order to test.
rental (namespaces.ts:646-670)
config() GET storefront/rental/config · catalog(opts) GET storefront/rental/items (rows) · get(sku) · availability({ sku, … }) GET …/items/<sku>/availability · quote({ sku, … }) POST …/items/<sku>/price · validateRestrictions({ sku, … }) · book(input) POST storefront/rental · list(opts) and getBooking(id): the signed-in customer's own rentals only (401 signed out; someone else's reads as 404) · cancel(id, reason) PUT storefront/rental/<id>/cancel: their own rental only · pay({ rentalId, … }) POST storefront/rental/<id>/payment records a payment, which is staff only (403 for a visitor or customer), so don't put it on a site page (rental.controller.ts). Not tested.
ticket, events (namespaces.ts:792-867)
ticket:list(opts)(rows),get(number),create(input)POST client-data/tickets,createWithAttachments({ attachments, … }),update(input),guestCreate({ email, … })POST crm/tickets/email/create/<email>,guestLookup({ email, number? }),collections()(rows). Creating a ticket emails people; see Support ticket flow.events:list(opts)GET client/events(rows),get(id),sessions(id),schedule(id),ticketTypes(id),participants(id),purchaseTickets(input),register(input),confirmOrder(input),myTickets(opts),getTicket(id),ticketQr(id),ticketPerks(id),transferTicket({ ticketId, … }),myParticipations(),respondToInvite({ participantId, … }),media(id),guestMedia({ eventId, guestId }),uploadMedia({ eventId, file, caption }),uploadGuestMedia(…),myBookings(). Not tested.
repository, page, site, files (namespaces.ts:869-1016)
| Method | Calls | Returns |
|---|---|---|
repository.find({ datatype, query, options }) ✓ | POST repository/find/<datatype> | rows (worked for a visitor on the test site) |
repository.findOne({ datatype, id }), findAnyId({ datatype, id }) | GET repository/get/…, /find-any-id/… (matches username, email, slug, name, sku, number or id) | one record |
repository.findByAttribute({ datatype, attribute, value }) | GET repository/find-by-attribute/… | |
findRelated, search, searchAdvanced, aggregate, categories, schema, isUnique | repository/find-related, /search, /aggregate, /category, /collections, /isunique (positional arguments, so from page code only) | |
page.getBySlug(slug) ✓, page.getIndex(), page.getPageData({ datatype, dataId }), page.sitemap() | site/page/<host>/<site>/<slug>, site/page-data/…, POST site/site-map | |
site.current() ✓, site.org(domain?), site.company(domain?), site.bySiteName(…), site.hostMappings(name?), site.appDsl(name) | site/get-site-by-hostname/<host>, site/get-org/…, site/company/…, … | |
files.list, upload({ file, path }), delete, signedUrl, myFiles(), uploadCustomerFile({ file, category }), deleteCustomerFile(path) | repository/customer/file/…, repository/file/signurl, client-data/files… |
Repository reads only; visitors see what the collection's access rules allow. Writing records from a page goes through a CRM form: see Put a form on a website page.
ui, state, form, media, navigate (no server)
| Method | Does |
|---|---|
ui.drawer(id, action?) / ui.drawer({ id, action }) ✓ | open / close / toggle (default) on the wm-drawer with that id; no such element → the site's sidebar |
ui.dialog(…) ✓, ui.modal(…) | the same for wm-dialog; no such element → the storefront quick view |
ui.nav(…), ui.search(action) | wm-navigation / wm-mega-menu, or the UI store's navOpen / searchOpen |
ui.scrollTo(selector, { behavior, block }) | smooth scroll |
ui.notify(message) ✓ | adds to the UI store's notices; nothing shows on a site page (tested) |
ui.confirm(message) | window.confirm, as a promise |
state.get(path), state.set(path, value) / set({ path, value }), inc, dec, toggle, subscribe(path, fn) ✓ | the page state the bindings read |
form.serialize(form), form.validate(form, rules), form.reset(form) | read, check and reset a form element; they don't send it |
media.play, pause, stop, volume, seek, next, prev, toggleMute, loadTrack({ url, title, cover }), isPlaying, animate({ target, action }) | <audio>/<video> on the page or the site's bottom player |
navigate(url, params?) ✓ | window.location.href, with params added as a query string |
blocknote.register(type, fn), unregister, list, render(json), renderMaybe(text) | BlockNote rendering for data-bind-html |
manifest() | the runtime's own list of attributes and methods (some examples in it are wrong; see the warnings above) |
_dispatch('ns.method', payload) | what data-action calls; the only way to reach activity, api and contentPlayer from code |
Reachable only through data-action or _dispatch
window.appmint.activity, .api and .contentPlayer are undefined (tested; index.ts:134-159), but the dispatcher has them (index.ts:90-111):
activity.<view|share|like|unlike|dislike|bookmark|follow|favorite|unfavorite|summary|stats|byResource|mine>with payload{ "datatype": "…", "id": "<sk>" }→stats/<datatype>/<id>/…. Toggles with no action read your state and flip it.rateandreactneed a second value and can't be reached this way. Onlyviewandsharework for a visitor who isn't signed in.api.get/api.delwith a path string:data-action="api.get" data-payload='"storefront/config"'(tested: it reached/api/storefront/config).api.post/put/patchcan't take a body this way; from page code usefetch('/api/<path>', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }).contentPlayer.<mine|outline|enroll|item|complete|progress|answer|saveDraft|applyBenefit>for Content Studio posts (client/content-studio/…).
An unknown namespace or method fails with appmint: unknown namespace "x" / appmint: x.y is not a method, which data-on-error="set-state:err={{message}}" can show.
When it's done
Tell the person: the page's URL; each live part and what it reads or does; where its code is (the page's Page settings, root's, or which Scripts file and on which pages it's linked); what you tested (signed out and signed in, one click each); and anything you didn't submit because it would email or text real people. Open the page on the site and in Build Studio.
What it can't do yet
- Toasts from the runtime.
notify:effects andui.notifyshow nothing on a site page. Useset-stateanddata-show. - Telling a visitor from a signed-in customer with
customer.signedIn/isSignedIn(). Both are alwaystrue. Usecustomer.current.data.email. - Sign-in and sign-up from page code.
customer.signIn/signUpfail for a visitor who isn't signed in. Link to the site's/login, or use a registration form (Registration that creates the customer). - Page JavaScript after an in-app move. It runs only on a full load; design the page so attributes carry what must work after
/login. - Ordered script files.
scriptLinksloadasync; there is no "run after" setting. - The Scripts record doing anything on its own. It isn't read by the site; its Status is ignored.
hovertriggers,{{$result.x}}, barerefresh,data-loopover plain strings,open-dialog. All tested broken; the workarounds are in the warnings above.activity.rate/reactandapi.postfrom attributes. One payload only; usefetch('/api/…').- Clear error messages. Errors usually say "Request failed with status code …"; read
err.statusanderr.details. data-bind-htmlsanitizing. Bind it only to text the business wrote.
Don'ts
- Don't put
<script>in the page HTML or in root's screens; use Page settings → Style or ascriptLinksfile. - Don't start page code without the design-mode guard and a wait for your element and
window.appmint. - Don't use a fixed
setTimeout(…, 2000)to "wait for the runtime". - Don't call
Number(),Math,JSONorDatein a binding; usedata-formator a method on the value. - Don't write
{{this.x}}; write{{x}}. Don't put text into JSON as"{{data.name}}"; use{{$json:data.name}}. - Don't put
data-actionon links, checkboxes withdata-trigger="click", or anything whose clicks your own code handles. - Don't rely on
customer.signedIn,notify:or a barerefresh. - Don't append a second copy of a script with
page_write; replace it withreplaceStyle: trueand the whole script. - Don't submit a real booking, ticket, sign-up or order to test.
- Don't change the
rootpage's script without checking it parses; it runs on every page.