# 结账扩展场景教程

> Shoplazza 结账扩展实战场景：探测全部扩展点、隐藏原生模块、监听事件、自动填充卡上姓名、做包邮进度条。

# Checkout extension 场景教程

本页收录基于 Checkout Extension 的实战场景。基础开发流程见 [创建一个结账扩展](/docs/app/extensions/checkout/add-checkout-extension)；查扩展点见[扩展点](/docs/app/extensions/checkout/extension-points)；查 API 见 [CheckoutAPI 参考](/docs/app/extensions/checkout/checkout-api)。

## 场景一：在所有扩展点插入自定义内容

- **适用场景**：开发调试阶段，快速确认每个扩展点在页面上的实际位置。

- **实现方式**：遍历所有扩展点，在每个位置渲染一个显示自身名称的红色标签。

**`src/index.js`**：

```javascript
import { extend } from 'shoplazza-extension-ui';

const extensionPoints = [
  'Checkout::RenderBefore',
  'Checkout::RenderAfter',
  'Checkout::Head::RenderAfter',
  'Checkout::FilledInformation::RenderAfter',
  'Checkout::SpecialInstruction::RenderAfter',
  'Checkout::Summary::RenderBefore',
  'Checkout::Navigate::RenderBefore',
  'Checkout::Navigate::RenderAfter',
  'Checkout::ContactInformation::RenderBefore',
  'Checkout::ContactInformation::RenderAfter',
  'Checkout::ShippingLinesTitle::RenderBefore',
  'Checkout::ShippingLinesTitle::RenderAfter',
  'Checkout::ShippingList::RenderAfter',
  'Checkout::ProductList::RenderBefore',
  'Checkout::ProductList::RenderAfter',
  'Checkout::Reductions::RenderBefore',
  'Checkout::Reductions::RenderAfter',
  'Checkout::SectionPayment::RenderBefore',
  'Checkout::SectionPayment::RenderAfter',
  'Checkout::ThankyouHeader::RenderBefore',
  'Checkout::ThankyouContent::RenderBefore',
];

function renderLabel(extensionPoint) {
  return `
    <div style="color:#dc2626;font-size:12px;">
      ${extensionPoint}
    </div>
  `;
}

extensionPoints.forEach(extensionPoint => {
  extend({
    extensionPoint,
    component: renderLabel(extensionPoint),
  });
});
```

确认各扩展点的位置后，将 `renderLabel` 返回的 HTML 替换为实际业务内容即可。

示例截图：

![Image](https://cnres.appracle.com/2d7c5ce6b5bbe5feab0ca91bf9b67328.png)

## 场景二：隐藏结账页原生模块

- **适用场景**：扩展自己渲染了联系信息和收货地址区块，结账页原生的「Contact」「Shipping address」标题变成多余内容，需要隐藏。

- **实现方式**：在扩展目录下新建 `extension.json`，与 `shoplazza.extension.toml` 同级，在里面声明要隐藏的 target。

**`extension.json`**：

```json
{ "deleteTarget": ["contactInformationHeader", "shippingAddressHeader"] }
```

重启 `shoplazza app dev` 后生效——dev server 不监听 `extension.json`。

预期效果：结账页上「Contact」「Shipping address」两个标题消失，下方的输入框仍在原位。

完整的 target 清单与 CLI 版本要求见 [隐藏原生模块](/docs/app/extensions/checkout/extension-reference#隐藏原生模块deletetarget)。

## 场景三：API 调用与事件监听

- **适用场景**：全面了解 `CheckoutAPI` 的可用方法与事件，可作为开发新扩展时的调试工具和参考起点。

- **实现方式**：在页面顶部渲染一组操作按钮，触发各类 API；同时监听价格、地址、步骤变化并在控制台输出。页面加载完成时打印所有 API 的初始值。

**`src/index.html`**：

```html
<style>
  .checkout-btn {
    margin: 4px;
    padding: 2px 4px;
    border: 1px solid #ccc;
    border-radius: 4px;
    cursor: pointer;
    background-color: #f0f0f0;
    color: #333;
    font-size: 12px;
    line-height: 1.4;
    transition: all 0.3s ease;
  }
  .checkout-btn:hover {
    background-color: #e0e0e0;
  }
</style>
<div data-coe-toolbar>
  <button class="checkout-btn" data-action="stepNavToInformation">跳转至联系信息</button>
  <button class="checkout-btn" data-action="stepNavToShipping">跳转至运输信息</button>
  <button class="checkout-btn" data-action="stepNavToPayment">跳转至支付信息</button>
  <button class="checkout-btn" data-action="doLogin">登录</button>
  <button class="checkout-btn" data-action="doRegister">注册</button>
  <button class="checkout-btn" data-action="doLogout">退出登录</button>
  <button class="checkout-btn" data-action="goToHomePage">跳转到首页</button>
  <button class="checkout-btn" data-action="locationHref">跳转到指定路径</button>
</div>
```

**`src/index.js`**：

```javascript
import { extend } from 'shoplazza-extension-ui';
import template from './index.html';

extend({
  extensionPoint: 'Checkout::RenderBefore',
  component: template,
});

// ⚠️ 以下 log / console.log 仅用于演示，生产请删除或改为可关闭的日志开关
const LOG = '[checkout-event-api]';
const REGISTRY_KEY = '__CHECKOUT_EVENT_API__';

function log(message, ...args) {
  console.log(`${LOG} ${message}`, ...args);
}

function getRegistry() {
  if (!window[REGISTRY_KEY]) {
    window[REGISTRY_KEY] = { shippingAddress: {} };
  }
  return window[REGISTRY_KEY];
}

function getApi() {
  return window.CheckoutAPI;
}

function makeAction(name, fn) {
  return async () => {
    log(`[action] ${name} → 开始执行...`);
    await fn();
    log(`[action] ${name} → 完成`);
  };
}

const actions = {
  stepNavToInformation: makeAction('跳转至联系信息步骤', () => getApi().step.stepNavToInformation()),
  stepNavToShipping: makeAction('跳转至运输信息步骤', () => getApi().step.stepNavToShipping()),
  stepNavToPayment: makeAction('跳转至支付信息步骤', () => getApi().step.stepNavToPayment()),
  doLogin: makeAction('登录', () => getApi().user.doLogin()),
  doRegister: makeAction('注册', () => getApi().user.doRegister()),
  doLogout: makeAction('退出登录', () => getApi().user.doLogout()),
  goToHomePage: makeAction('跳转至首页', () => getApi().step.goToHomePage()),
  locationHref: makeAction('跳转至指定路径', () => getApi().step.locationHref('/')),
};

// 用事件委托替代 inline onclick，避免全局命名空间污染
document.addEventListener('click', (event) => {
  const btn = event.target.closest('[data-action]');
  if (!btn || !btn.closest('[data-coe-toolbar]')) return;
  const action = actions[btn.dataset.action];
  if (action) action();
});

const MOUNT_INTERVAL_MS = 100;
const MOUNT_MAX_ATTEMPTS = 50; // 100ms * 50 ≈ 5 秒

function mount(attempt = 0) {
  const api = getApi();
  if (!api) {
    if (attempt >= MOUNT_MAX_ATTEMPTS) {
      console.warn(`${LOG} CheckoutAPI 未在预期时间内挂载，已放弃等待`);
      return;
    }
    // 用 setTimeout 而不是 requestAnimationFrame，避免 60fps 忙等占用结账页 CPU
    window.setTimeout(() => mount(attempt + 1), MOUNT_INTERVAL_MS);
    return;
  }

  const reg = getRegistry();

  if (reg.handlePricesChange) api.store.removePricesChangeCb(reg.handlePricesChange);
  if (reg.handleAddressChange) api.address.removeShippingAddressChangeCb(reg.handleAddressChange);
  if (reg.handleStepChange) api.step.removeStepChangeCb(reg.handleStepChange);

  reg.handlePricesChange = (prices) => log('[prices-change]', prices);
  reg.handleAddressChange = (patch) => {
    reg.shippingAddress = { ...reg.shippingAddress, ...patch };
    log('[address-change]', patch);
  };
  reg.handleStepChange = () => log('[step-change]', api.step.getStep());

  api.store.onPricesChange(reg.handlePricesChange);
  api.address.onShippingAddressChange(reg.handleAddressChange);
  api.step.onStepChange(reg.handleStepChange);

  console.group(`${LOG} 初始化完成`);
  log('step:', api.step.getStep());
  log('orderInfo:', api.store.getOrderInfo());
  log('orderStatus:', api.store.getOrderStatus());
  log('referInfo:', api.store.getReferInfo());
  log('prices:', api.store.getPrices());
  log('products:', api.summary.getProductList());
  log('user.isLogin:', api.user.isLogin());
  log('user.info:', api.user.getUserInfo());
  log('shippingAddress:', api.address.getShippingAddress());
  console.groupEnd();
}

mount();
```

**关键点说明：**

- **事件委托替代 inline onclick**：所有按钮挂 `data-action="xxx"`，统一在 `document` 监听 `click` 事件并通过 `closest('[data-action]')` 定位目标。

- `makeAction`：统一包装异步操作，执行前后打印日志。

- `mount`：用 `setTimeout` + 最大重试次数（约 5 秒）等待 `CheckoutAPI` 挂载，避免 `requestAnimationFrame` 60fps 忙等占用结账页 CPU。

- 注册事件监听前先调用对应的 `remove*` 方法清除旧回调，防止热更新时重复注册。

- `getRegistry`：将回调函数引用存储在 `window` 全局对象上，确保 `remove*` 时传入的是同一个函数引用。

## 场景四：地址自动填充到信用卡姓名

- **适用场景**：顾客在联系信息步骤填写收货地址后，跳转到支付步骤时，自动将姓名填入信用卡持卡人姓名输入框，减少重复输入。

- **结账布局说明**：Shoplazza 支持三种结账布局，可在店铺后台「结账页编辑器 → 结账页基础配置 → 结账布局」中切换。本扩展通过同时监听 `onShippingAddressChange` 和 `onStepChange` 两个事件，并在 `mount` 时判断当前步骤主动触发填充，覆盖三种布局下的不同填充时机，无需针对各布局单独适配。

**核心逻辑：**

1. 监听收货地址变化，将每次变化的字段合并到本地缓存

2. 监听步骤变化，当切换到 `payment_method` 时触发填充

3. `mount` 时若当前已经处于 `payment_method` 或 `contact_information` 步骤，直接尝试填充（覆盖刷新落到这两步的场景）

4. 填充时通过 DOM 查询信用卡姓名输入框，若输入框尚未渲染则每 100ms 重试，最多等待 5 秒

:::warning
**DOM 选择器警告：** `#card_first_name` / `#card_last_name` 等是 Shoplazza 结账页**当前版本的内部 DOM 结构**，可能随平台升级而变化。生产扩展应：(1) 尽量通过 `CheckoutAPI` 等公开接口实现需求；(2) 必须直接操作 DOM 时，加版本检测和优雅降级，并定期回归。
:::

**`src/index.js`**：

```javascript
// ⚠️ 以下 log / console.log 仅用于演示，生产请删除或改为可关闭的日志开关
const LOG = '[checkout-name-autofill]';
const REGISTRY_KEY = '__CHECKOUT_NAME_AUTOFILL__';

function log(message, ...args) {
  console.log(`${LOG} ${message}`, ...args);
}

function getRegistry() {
  if (!window[REGISTRY_KEY]) {
    window[REGISTRY_KEY] = { shippingAddress: {} };
  }
  return window[REGISTRY_KEY];
}

function getApi() {
  return window.CheckoutAPI;
}

function trimText(value) {
  return typeof value === 'string' ? value.trim() : '';
}

function setInputValue(input, value) {
  if (!input) return;
  input.value = value;
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
}

function tryFillCardholderName(address, remaining = 50) {
  const firstName = trimText(address?.firstName);
  const lastName = trimText(address?.lastName);

  if (!firstName && !lastName) return;

  const firstNameInput =
    document.querySelector('#card_first_name') ||
    document.querySelector('input[name="card_first_name"]');
  const lastNameInput =
    document.querySelector('#card_last_name') ||
    document.querySelector('input[name="card_last_name"]');

  if (!firstNameInput || !lastNameInput) {
    if (remaining <= 0) return;
    window.setTimeout(() => tryFillCardholderName(address, remaining - 1), 100);
    return;
  }

  if (firstName) setInputValue(firstNameInput, firstName);
  if (lastName) setInputValue(lastNameInput, lastName);

  log('[cardholder-autofill] 填充完成', { firstName, lastName });
}

function handleAddressChange(patch) {
  const reg = getRegistry();
  reg.shippingAddress = { ...reg.shippingAddress, ...patch };
  log('[address-change]', patch);
  tryFillCardholderName(reg.shippingAddress);
}

function handleStepChange() {
  if (getApi().step.getStep() === 'payment_method') {
    tryFillCardholderName(getRegistry().shippingAddress);
  }
}

const MOUNT_INTERVAL_MS = 100;
const MOUNT_MAX_ATTEMPTS = 50; // 100ms * 50 ≈ 5 秒

function mount(attempt = 0) {
  const api = getApi();
  if (!api) {
    if (attempt >= MOUNT_MAX_ATTEMPTS) {
      console.warn(`${LOG} CheckoutAPI 未在预期时间内挂载，已放弃等待`);
      return;
    }
    // 用 setTimeout 替代 requestAnimationFrame，避免 60fps 忙等占用结账页 CPU
    window.setTimeout(() => mount(attempt + 1), MOUNT_INTERVAL_MS);
    return;
  }

  const reg = getRegistry();

  if (reg.handleAddressChange) api.address.removeShippingAddressChangeCb(reg.handleAddressChange);
  if (reg.handleStepChange) api.step.removeStepChangeCb(reg.handleStepChange);

  reg.handleAddressChange = handleAddressChange;
  reg.handleStepChange = handleStepChange;

  reg.shippingAddress = api.address.getShippingAddress();

  api.address.onShippingAddressChange(handleAddressChange);
  api.step.onStepChange(handleStepChange);

  // 当前已在支付步骤或联系信息步骤时，主动尝试一次填充（覆盖刷新/直接进入这些步骤的场景）
  const currentStep = api.step.getStep();
  if (currentStep === 'payment_method' || currentStep === 'contact_information') {
    tryFillCardholderName(reg.shippingAddress);
  }
}

mount();
```

**关键点说明：**

- `tryFillCardholderName`：查找信用卡姓名输入框，若未渲染则每 100ms 重试，最多尝试 50 次（约 5 秒）。同时兼容 ID 选择器和 name 属性选择器两种 DOM 结构。

- `setInputValue`：填充 input 值后手动触发 `input` 和 `change` 事件，确保页面框架能感知到值的变化。

- `handleAddressChange`：地址每次变化时合并最新 patch，并尝试立即填充（覆盖单步结账布局场景）。

- `handleStepChange`：步骤切换到支付页时触发填充（覆盖多步结账布局场景）。

- `mount` 中的初始填充：处理页面直接加载在特定步骤时的填充（覆盖直接进入支付页的场景）。

- 本扩展无需渲染 UI，不调用 `extend`，仅在后台运行逻辑。

## 场景五：根据购物车金额展示免运费进度条

- **适用场景**：引导顾客凑单。当商品小计低于阈值时，提示还差多少金额解锁免运费；达标后展示已享免运费。购物车变化时进度条实时更新。

- **实现思路**：通过 `CheckoutAPI.store.getPrices()` 读取商品小计，在某个结账扩展点渲染提示条，并在 `CheckoutAPI.store.onPricesChange()` 回调里重新渲染。

:::note
本场景聚焦业务逻辑。`CheckoutAPI` 的挂载等待写法（`mount`）和完整 API 巡览，参见上文场景三。
:::

:::note
本场景只是展示提示条，**并不会真的免运费**。真正的免运费规则需由商家在店铺后台中配置。请让 `FREE_SHIPPING_THRESHOLD` 与该规则保持一致，否则可能出现“进度条说免运、结账却照收运费”的不一致。
:::

**`src/index.js`**：

```javascript
import { extend } from 'shoplazza-extension-ui';

// 免运费阈值（按店铺币种）。请按你的活动修改
// （例如改为从扩展设置读取，而非写死）。
const FREE_SHIPPING_THRESHOLD = 120;

// `CheckoutAPI` 只有在结账页完全加载后才挂载到 `window` 上。
// 过早访问 `window.CheckoutAPI` 会得到 `undefined`，
// 因此用轮询等待，而非直接读取。
function mount(cb) {
  let tries = 0;
  const timer = setInterval(() => {
    if (window.CheckoutAPI) {
      // 已就绪：停止轮询，把 API 交给回调。
      clearInterval(timer);
      cb(window.CheckoutAPI);
    } else if (++tries > 50) {
      // 约 5 秒后（50 次 × 100ms）放弃，避免无限循环。
      clearInterval(timer);
      console.warn('CheckoutAPI not mounted in time');
    }
  }, 100);
}

// 根据当前商品小计生成提示条的 HTML 字符串。
function render(subtotal) {
  const remaining = FREE_SHIPPING_THRESHOLD - subtotal;
  // 低于阈值：提示还差多少金额；达标：提示已解锁免运费。
  const text =
    remaining > 0
      ? `Spend $${remaining.toFixed(2)} more to unlock free shipping`
      : 'You have unlocked free shipping!';
  return `<div style="padding:12px;background:#f5f5f5;">${text}</div>`;
}

mount((api) => {
  // 在固定的结账扩展点渲染（或重渲染）提示条。
  // `subtotalPrice` 是字符串，运算前先 `parseFloat`。
  const draw = (prices) =>
    extend({
      extensionPoint: 'Checkout::Summary::RenderBefore',
      component: render(parseFloat(prices.subtotalPrice)),
    });

  // 先用初始价格画一次……
  draw(api.store.getPrices());
  // ……之后每当购物车金额变化就自动重画。
  api.store.onPricesChange((prices) => draw(prices));
});
```

![免运费进度条预览](https://cnres.appracle.com/8e6432238d7e3a8c252423c4077357d0.png)
