按尺寸计价销售商品
让顾客在商品页填写尺寸,价格随之变化——按平方米卖的定制壁画、按长度卖的壁纸卷、按米卖的布料。第 1 到 6 步用店铺前台 JavaScript 和 Ajax API 实现,整条购买链路都在主题内完成。只有第 7 步——从订单读回尺寸——会调用 Open API。
本篇是行项目属性的一个完整应用示例。通用规则——属性怎么写入、顾客看到什么、怎么读回——先看 给购物车和订单添加自定义属性。
背景
Shoplazza 的商品价格挂在变体上,而变体价格是固定的。按尺寸售卖的商家因此被卡住:
- 商品页能显示算好的价格(「6 m² × $19.90 = $119.40」),但那只是一段文字,进不了购物车。
- 无论顾客填了多大尺寸,加购时都只按变体价收一次钱。
- 顾客填的尺寸也丢了,生产这件商品的工厂拿不到任何依据。
三件事的根源是同一个:整条购买链路里没有任何入口接收前台算出来的价格。
但这条链路里有一样东西是你能控制的——数量。如果变体价格代表的不是「一件成品」而是「一个很小的计量单位」,数量就能承载尺寸,平台自己的算术会把总价算对。尺寸本身则作为行项目属性一同带走。
因此本篇的核心目标是:让购物车金额与顾客填写的尺寸一致,并把这个尺寸一路带到订单上——全程不改变体价格。
工作原理
整套做法分五个阶段:
- 把价格建模成计量单位。 把变体价格设为一个计价单位的价格——例如每
0.1 m²卖$1.99,也就是每平方米$19.90。 - 收集尺寸。 商品页上的 App Block 渲染尺寸输入框,并实时显示总价。
- 换算成计价份数。
份数 = ceil(面积 / 计价单位)。向上取整,保证不会少收钱。 - 拦截购买按钮。 在主题原生的「加入购物车」和「立即购买」按钮上挂捕获阶段的 click 处理函数,让原生逻辑不会带着
quantity: 1跑起来。 - 提交。 加购走
POST /{locale}/api/cart,立即购买走POST /{locale}/api/checkout/order。两者都带上quantity(计价份数)和properties(尺寸)。
尺寸作为行项目属性随单流转,最终落到订单的 custom_properties 上,你的履约系统从这里读取。
前置条件
- 一个 App Block 类型的主题应用扩展——见 开发主题扩展。App Block 渲染在页面内部,商家在主题编辑器里把它放进购买区即可,你不需要去猜主题的 DOM 结构。
- 一个变体价格等于一个计价单位、并且已关闭库存跟踪的商品。这一条不是可选项,原因见第 1 步的警告。
- 了解 购物车 Ajax API 与 结账 Ajax API。
第 1 步:设置商品
选一个计价单位,小到让取整误差可以接受,然后把变体价格设成这个单位的价格。
一幅每平方米 $19.90 的壁画,取 0.1 m² 作为计价单位,变体价格就是 $1.99。一幅 2 m × 3 m 的壁画是 6 m²,也就是 60 个计价份数,顾客支付 $1.99 × 60 = $119.40。
单位越小,取整误差越小,数量数字也越大。0.01 m² 的精度高 10 倍,但同一幅壁画在顾客购物车里会显示成 600。
这个商品必须关闭库存跟踪。 打开库存跟踪后,库存数就不再表示「还剩几件」,而是「还剩几个计价份数」——卖 6 m² 需要 60 个库存。更麻烦的是两条购买路径的失败方式并不一样:
POST /api/cart直接拒绝请求,返回 HTTP 406 和You can only add 10 pieces to the cart.。POST /api/checkout/order返回 HTTP 200,但会静默把数量截断到剩余库存。顾客看到的是 6 m²、$119.40,实际建单是 $19.90,而custom_properties里仍然写着Area: 6.00 m²。全程没有任何报错,工厂照着属性做出一幅 6 m² 的画,收的却是 1 m² 的钱。
如果商家确实需要对这个商品做库存管控,只能在平台的库存字段之外另想办法。
第 2 步:渲染尺寸输入框
App Block 承载输入框和实时总价,并通过一个全局变量把商品上下文传给你的脚本。
{% use "area-pricing.css" %}
{% use "area-pricing.js" %}
<div class="ap-root" data-ap-root>
<label>宽度(cm) <input type="number" min="1" step="1" value="200" data-ap-width></label>
<label>高度(cm) <input type="number" min="1" step="1" value="300" data-ap-height></label>
<div class="ap-summary">
<span>面积:<b data-ap-area>—</b></span>
<span>计价份数:<b data-ap-units>—</b></span>
<span>总价:<b data-ap-total>—</b></span>
</div>
<p class="ap-note" data-ap-note></p>
</div>
<script>
window.__areaPricing = {
productId: {{ product.id | json }},
// product.variants.first 在 Shoplazza 上返回 null,要用数组下标取
variantId: {{ product.variants[0].id | json }},
unitPrice: {{ product.variants[0].price | json }}, // 字符串,例如 "1.99"
unitArea: {{ block.settings.unit_area | default: 0.1 | json }}
};
</script>
{% schema %}
{
"name": { "en-US": "Area pricing", "zh-CN": "按面积计价" },
"settings": [
{
"type": "text",
"id": "unit_area",
"label": { "en-US": "Billing unit in m²", "zh-CN": "计价单位(m²)" },
"default": "0.1"
}
]
}
{% endschema %}
product.variants.first 在这里返回 null,要改用 product.variants[0](或者用带 limit: 1 的 {% for %} 循环)。product.variants[0].price 取到的是字符串(如 "1.99"),参与运算前要先转成数字。
本篇假设商品是单变体的,这也是定制类商品通常的形态:尺寸是输入项,不是变体。如果你的商品确实有多个变体,当前选中的变体要从主题自己的变体控件读取,而不是取 variants[0]。
第 3 步:把尺寸换算成计价份数
向上取整。 顾客填 205 cm × 305 cm,需要 6.2525 m²,也就是 62.525 个计价份数——而数量必须是整数。向上取整按 63 份收费,商家不会少收;向下取整或四舍五入都会以低于成本的价格卖出材料。
var cfg = window.__areaPricing;
var UNIT_AREA = parseFloat(cfg.unitArea) || 0.1; // 一个变体价格覆盖多少平方米
var UNIT_PRICE = parseFloat(cfg.unitPrice) || 0;
function calc() {
var w = parseFloat(document.querySelector('[data-ap-width]').value);
var h = parseFloat(document.querySelector('[data-ap-height]').value);
if (!(w > 0) || !(h > 0)) return null;
var area = (w * h) / 10000; // cm² 换算成 m²
var units = Math.ceil(area / UNIT_AREA); // 向上取整:绝不少收
return { w: w, h: h, area: area, units: units, total: units * UNIT_PRICE };
}
顾客边输入边显示结果,让他看到的价格就是最终要付的价格:
function render() {
var r = calc();
var root = document.querySelector('[data-ap-root]');
root.querySelector('[data-ap-area]').textContent = r ? r.area.toFixed(2) + ' m²' : '—';
root.querySelector('[data-ap-units]').textContent = r ? String(r.units) : '—';
root.querySelector('[data-ap-total]').textContent = r ? r.total.toFixed(2) : '—';
}
document.querySelector('[data-ap-width]').addEventListener('input', render);
document.querySelector('[data-ap-height]').addEventListener('input', render);
render();
填 200 × 300 时,这里显示面积:6.00 m²、计价份数:60、总价:119.40。
第 4 步:拦截购买按钮
主题原生的「加入购物车」和「立即购买」按钮会提交 quantity: 1。给它们各挂一个捕获阶段的 click 处理函数,让你的代码先跑,原生处理函数不会被触发。
这一步的风险不在于「挂不上」,而在于挂错了按钮。劫持一个认错的按钮等于把它改写成加购动作;如果那个按钮恰好是购物车抽屉的「结算」,顾客就彻底下不了单了。所以作用域要收紧:只在当前这个商品的商品表单里找。
// 限定在本商品的商品表单内。不要只用 [product-id="<id>"] 选择——
// 页面上其他模块(比如「最近浏览」)带的是同一个 product-id,
// 它们的按钮会被一起捞进来。
function scopes() {
var pid = cfg.productId;
return [].slice.call(document.querySelectorAll(
'spz-product-form[product-id="' + pid + '"], form[product-id="' + pid + '"]'
));
}
// 判断按钮类型。带 data-track-source 的主题上它是最可靠的信号;
// 类名和文案是给没有这个属性的主题兜底用的。
function buyActionOf(el) {
var src = (el.getAttribute('data-track-source') || '').toLowerCase();
if (src === 'buy_now' || src === 'add_to_cart') return src;
var cls = String(el.className || '');
if (/buy[-_]?now/i.test(cls)) return 'buy_now';
if (/add[-_]?to[-_]?cart/i.test(cls)) return 'add_to_cart';
return null;
}
function bind() {
scopes().forEach(function (scope) {
var candidates = [].slice.call(scope.querySelectorAll('button, spz-atc, [type="submit"]'));
// 有些主题上,作用域根节点自己就是目标按钮
if (scope.matches('button, spz-atc, [type="submit"]')) candidates.unshift(scope);
candidates.forEach(function (el) {
var action = buyActionOf(el);
if (!action || el.__apBound) return;
el.__apBound = true;
el.__apAction = action;
el.addEventListener('click', onBuyClick, true); // true = 捕获阶段
});
});
}
bind();
// 主题在切换变体或改数量时会重绘购买区,按钮节点会被换掉,
// 发生变化时重新绑定。
new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
劫持这条路径上的作用域判断必须严格。「带有主题的主按钮类名」这种通用信号会命中登录按钮、空购物车按钮,以及购物车抽屉的「结算」——它们都不在任何商品表单里。多藏一个按钮还能补救,劫持错一个补救不了。
第 5 步:加入购物车
把计价份数作为 quantity 提交,尺寸作为 properties 提交。
不带下划线前缀的键对顾客可见,带前缀的隐藏但 API 仍会返回(见 下划线前缀的作用)。下面用 Width / Height / Area 作为可见键,你可以按店铺面向的语言自行调整。
function root_() {
// 多语言店铺的路径带 locale 前缀,URL 一律从这里拼
return window.SHOPLAZZA.routes.root;
}
function buildProperties(r) {
return {
'Width': r.w + ' cm', // 顾客可见
'Height': r.h + ' cm',
'Area': r.area.toFixed(2) + ' m²',
'_billing_unit_area': String(UNIT_AREA), // 隐藏:给履约系统读
'_billing_units': String(r.units)
};
}
function addToCart(r) {
return fetch(root_() + '/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: cfg.productId,
variant_id: cfg.variantId,
quantity: r.units, // 计价份数,不是件数
properties: buildProperties(r),
refer_info: { source: 'add_to_cart' }
})
}).then(function (res) {
if (!res.ok) throw new Error('cart_add_failed ' + res.status);
return res.json();
});
}
加购后读回购物车,可以看到平台实际存下来的行项目:
{
"cart": {
"item_count": 60,
"total_price": "119.40",
"line_items": [
{
"variant_id": "36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105",
"quantity": "60",
"price": "1.99",
"properties": "{\"Width\":\"200 cm\",\"Height\":\"300 cm\",\"Area\":\"6.00 m²\",\"_billing_unit_area\":\"0.1\",\"_billing_units\":\"60\"}"
}
]
}
}
quantity 返回的是字符串,properties 返回的是一段需要你自己解析的 JSON 字符串。而在订单侧,同一份数据出现在 custom_properties 下,是对象——见第 7 步。两处形态不同,各自按各自的形态处理。
加购之后不需要调用任何主题接口去刷新购物车。主题监听的是 POST /api/cart 这个请求本身,它会自己打开购物车抽屉。
第 6 步:立即购买
立即购买不经过购物车。它用 POST /{locale}/api/checkout/order 直接创建一个结账会话,然后把顾客跳转到返回的地址。
line_items[] 的每一行都接受 properties,与购物车接口完全一致。不要传价格——服务端按变体自己定价,这正是整套做法成立的前提。
function buyNow(r) {
return fetch(root_() + '/api/checkout/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refer_info: { source: 'buy_now' },
line_items: [{
product_id: cfg.productId,
variant_id: cfg.variantId,
quantity: r.units,
note: '',
properties: buildProperties(r)
}]
})
}).then(function (res) {
if (!res.ok) throw new Error('checkout_create_failed ' + res.status);
return res.json();
}).then(function (body) {
var url = body.data && body.data.checkout_url;
if (!url) throw new Error('checkout_url_missing');
location.href = url; // 直达结账页,不动购物车
});
}
一幅 6 m² 壁画的响应(节选):
{
"state": "success",
"data": {
"order_token": "2446407211694842140211",
"checkout_url": "/checkout/2446407211694842140211",
"prices": {
"total_price": "119.40",
"subtotal_price": "119.40",
"currency_code": "USD"
}
}
}
第 4 步绑定的点击处理函数按按钮类型分发到这两个提交函数:
var busy = false;
function onBuyClick(e) {
e.preventDefault();
e.stopPropagation(); // 不能让主题自己的处理函数再跑一遍
if (busy) return;
var r = calc();
if (!r) { note('请填写宽度和高度。'); return; }
busy = true;
var request = e.currentTarget.__apAction === 'buy_now' ? buyNow(r) : addToCart(r);
request.then(function () {
busy = false;
note('已加入:' + r.units + ' 份,' + r.area.toFixed(2) + ' m²');
}, function (err) {
busy = false;
note('这个尺寸没能加入购物车,请重试。');
console.warn('[area-pricing]', err);
});
}
这样创建结账会话不会动购物车,也符合顾客对「立即购买」的预期。如果改成走购物车来实现立即购买,有两个副作用值得避开:主题看到任何一次 POST /api/cart 都会弹出购物车抽屉;顾客在结账页放弃支付时,这件商品还会留在购物车里。
第 7 步:从订单读取尺寸
这一步跑在你的服务端,不在主题里。它需要一个已完成 OAuth 授权与 token 存储的应用——见 开发独立应用——以及 order 访问权限范围。
在订单上,你提交的属性出现在每个行项目的 custom_properties 下,是已经解析好的对象:
GET /openapi/2026-01/orders/{id}
{
"order": {
"number": "JMW07973",
"total_price": "125.37",
"line_items": [
{
"quantity": 63,
"price": "1.99",
"custom_properties": {
"Width": "205 cm",
"Height": "305 cm",
"Area": "6.25 m²",
"_billing_unit_area": "0.1",
"_billing_units": "63"
}
}
]
}
}
带下划线前缀的键虽然任何界面上都不显示,在这里是取得到的。同一个对象也会随 orders/create webhook 推送过来,履约类集成无需轮询即可拿到尺寸——见 监听订单事件。
写入时这个字段叫 properties,从订单读回时叫 custom_properties。在订单行项目上读 properties 会得到空值,而且不报错。
验证
上线前在测试店逐条走一遍:
- 块的位置——在商品模板的主题编辑器里把这个块加到购买区,确认店铺前台的尺寸输入框出现在那里。
- 实时总价——填 200 和 300,块上应显示
面积:6.00 m²、计价份数:60、总价:119.40。 - 取整——填 205 和 305,面积是 6.2525 m²,计价份数必须是 63,不是 62。
- 加入购物车——点主题原生的「加入购物车」。购物车里应有一条数量为 60、总价
119.40的行项目,购物车页上 Width、Height、Area 各占一行,两个下划线键不出现。 - 立即购买——点主题原生的「立即购买」。应直接进入结账页,购物车保持点击之前的状态,结账页摘要显示计价份数和三条尺寸。
- 订单——完成下单后在后台打开订单。行项目显示
1.99 USD x 63,三条尺寸各占一行,两个下划线键不出现在页面上、但在 API 响应里取得到。 - 没有误劫持——在块生效的状态下打开购物车抽屉、点「结算」。它必须进入结账,而不是往购物车里再加一件。这一条用来验第 4 步的按钮作用域有没有放太宽。
采用之前要权衡的取舍
数量承载了尺寸,于是平台上一切按数量计算的东西,现在算的都是计价份数:
- 数量是露出来的。 购物车角标、结账页摘要、后台订单页显示的都是 60 而不是 1,后台订单头部会写「63 件产品」,仓库和打包单据上也是同一个数字。
- 按件数的促销会提前触发。 一个「满 3 件减 $5」的自动折扣,任何超过 0.3 m² 的订单都会命中——测试店实测:一幅 6 m² 的壁画触发了它,总价从
$119.40降到$114.40。按件数分档的促销活动要把这类商品排除掉。 - 按件数计费的运费方案会被放大。 按订单金额计费的方案不受影响,因为金额本身是对的。按件数或按重量计费、且可能应用到这类商品的方案要逐个检查。
- 不能使用库存跟踪。 见第 1 步的警告。
如果商家需要数量保持它原本的含义——因为促销、运费和仓库都依赖它——那这套做法就不适合,尺寸只能换一种方式定价。