Skip to main content

结账扩展

Shopify 的结账是互联网上转化率最高的结账系统,并且它是完全可扩展的。结账扩展允许你添加自定义 UI、修改支付和运费选项、验证购物车内容,以及创建购后体验——所有这些都无需触碰 Shopify 的核心结账代码。这些扩展在安全的沙箱环境中运行,确保结账性能和安全性永远不会受到影响。

Checkout Extensibility

Shopify 已从 checkout.liquid 自定义转向基于组件的扩展模型。所有新的结账自定义都应使用结账扩展。checkout.liquid 方式对新商店已弃用,最终也将从现有的 Shopify Plus 商店中移除。

结账 UI 扩展

结账 UI 扩展在结账流程的特定位置渲染自定义组件。它们是最常见的结账扩展类型。

扩展目标

结账 UI 扩展可以在以下位置渲染:

目标位置常见用例
purchase.checkout.block.render结账主体自定义字段、追加销售、忠诚度信息
purchase.checkout.header.render-after结账头部下方横幅、信任徽章
purchase.checkout.footer.render-before结账底部上方法律声明、保证
purchase.checkout.delivery-address.render-before运送地址之前礼品留言、配送说明
purchase.checkout.shipping-option-list.render-after运费选项之后运输保护、配送备注
purchase.checkout.payment-method-list.render-before支付方式之前忠诚度积分兑换
purchase.thank-you.block.render感谢页面购后调查、推荐
purchase.order-status.block.render订单状态页追踪、支持、评价

构建结账 UI 扩展

# Generate a checkout UI extension
shopify app generate extension --template checkout_ui --name loyalty-points-checkout
// extensions/loyalty-points-checkout/src/Checkout.tsx
import {
reactExtension,
Banner,
BlockStack,
Button,
Checkbox,
Divider,
Heading,
InlineLayout,
Text,
useApi,
useApplyDiscountCodeChange,
useCartLines,
useTotalAmount,
} from '@shopify/ui-extensions-react/checkout';
import { useEffect, useState } from 'react';

const checkoutBlock = reactExtension(
'purchase.checkout.block.render',
() => <LoyaltyPointsRedemption />
);
export default checkoutBlock;

function LoyaltyPointsRedemption() {
const { buyerIdentity } = useApi();
const cartLines = useCartLines();
const totalAmount = useTotalAmount();
const applyDiscountCode = useApplyDiscountCodeChange();

const [loyaltyData, setLoyaltyData] = useState<{
pointsBalance: number;
maxRedeemable: number;
dollarValue: number;
} | null>(null);
const [redeemPoints, setRedeemPoints] = useState(false);
const [applied, setApplied] = useState(false);

const customerEmail = buyerIdentity?.email?.current;

useEffect(() => {
async function fetchLoyalty() {
if (!customerEmail) return;

try {
const response = await fetch(
`/api/loyalty/checkout-balance?email=${encodeURIComponent(customerEmail)}&total=${totalAmount.amount}`
);
const data = await response.json();
setLoyaltyData(data);
} catch (err) {
console.error('Failed to fetch loyalty data');
}
}

fetchLoyalty();
}, [customerEmail, totalAmount.amount]);

async function handleRedeem() {
if (!loyaltyData) return;

try {
// Apply a discount code that corresponds to the points redemption
const result = await applyDiscountCode({
type: 'addDiscountCode',
code: `LOYALTY-${customerEmail}-${loyaltyData.maxRedeemable}`,
});

if (result.type === 'success') {
setApplied(true);
}
} catch (err) {
console.error('Failed to apply loyalty discount');
}
}

if (!loyaltyData || loyaltyData.pointsBalance === 0) {
return null; // Don't render anything if no points available
}

return (
<BlockStack spacing="base">
<Heading level={2}>Loyalty Points</Heading>
<Divider />

<InlineLayout columns={['fill', 'auto']}>
<Text>Available Points</Text>
<Text emphasis="bold">
{loyaltyData.pointsBalance.toLocaleString()}
</Text>
</InlineLayout>

<InlineLayout columns={['fill', 'auto']}>
<Text>Redeemable Value</Text>
<Text emphasis="bold">
${loyaltyData.dollarValue.toFixed(2)}
</Text>
</InlineLayout>

{!applied ? (
<BlockStack spacing="tight">
<Checkbox
checked={redeemPoints}
onChange={setRedeemPoints}
>
Apply {loyaltyData.maxRedeemable.toLocaleString()} points
(-${loyaltyData.dollarValue.toFixed(2)})
</Checkbox>

{redeemPoints && (
<Button kind="secondary" onPress={handleRedeem}>
Redeem Points
</Button>
)}
</BlockStack>
) : (
<Banner status="success">
{loyaltyData.maxRedeemable.toLocaleString()} points applied!
You saved ${loyaltyData.dollarValue.toFixed(2)}.
</Banner>
)}
</BlockStack>
);
}
Checkout UI Performance

结账扩展必须在 500ms 内加载。保持你的包体积小,在渲染期间最小化 API 调用,并使用 Shopify 的内置 hooks (useCartLines, useTotalAmount, useBuyerIdentity) instead of making separate API requests for data Shopify already provides.

购后扩展

购后扩展在客户完成支付后但在感谢页面之前立即渲染。这是追加销售和交叉销售的高转化时机。

// extensions/post-purchase-upsell/src/PostPurchase.tsx
import {
reactExtension,
useApi,
BlockStack,
Button,
Heading,
Image,
InlineLayout,
Text,
TextBlock,
} from '@shopify/ui-extensions-react/post-purchase';
import { useState } from 'react';

const postPurchase = reactExtension(
'purchase.post.render',
() => <PostPurchaseUpsell />
);
export default postPurchase;

function PostPurchaseUpsell() {
const { inputData, calculateChangeset, applyChangeset, done } = useApi();
const [loading, setLoading] = useState(false);
const [accepted, setAccepted] = useState(false);

// The offer is determined server-side and passed through inputData
const offer = inputData.initialPurchase
? getRecommendedOffer(inputData.initialPurchase)
: null;

if (!offer) {
done(); // No offer to show, proceed to thank-you
return null;
}

async function handleAccept() {
setLoading(true);

// Calculate the new order total with the upsell item
const changeset = await calculateChangeset({
changes: [
{
type: 'add_variant',
variantId: offer.variantId,
quantity: 1,
discount: {
value: offer.discountPercentage,
valueType: 'percentage',
title: 'Post-purchase exclusive',
},
},
],
});

// Apply the changeset -- charges the customer immediately
await applyChangeset(changeset.token);
setAccepted(true);
setLoading(false);
}

function handleDecline() {
done(); // Proceed to thank-you page
}

if (accepted) {
return (
<BlockStack spacing="loose">
<Heading>Added to your order!</Heading>
<Text>
{offer.title} has been added. Your card has been charged an
additional ${offer.discountedPrice.toFixed(2)}.
</Text>
<Button onPress={() => done()}>Continue</Button>
</BlockStack>
);
}

return (
<BlockStack spacing="loose">
<Heading>Wait -- exclusive offer just for you!</Heading>

<InlineLayout columns={['auto', 'fill']} spacing="base">
<Image source={offer.imageUrl} alt={offer.title} />
<BlockStack spacing="tight">
<Text emphasis="bold" size="large">{offer.title}</Text>
<TextBlock>{offer.description}</TextBlock>
<InlineLayout columns={['auto', 'auto']} spacing="tight">
<Text appearance="subdued" accessibilityRole="deletion">
${offer.originalPrice.toFixed(2)}
</Text>
<Text emphasis="bold">
${offer.discountedPrice.toFixed(2)}
</Text>
</InlineLayout>
<Text size="small" appearance="subdued">
{offer.discountPercentage}% off -- this offer expires when
you leave this page
</Text>
</BlockStack>
</InlineLayout>

<InlineLayout columns={['fill', 'fill']} spacing="base">
<Button kind="primary" onPress={handleAccept} loading={loading}>
Add to Order
</Button>
<Button kind="plain" onPress={handleDecline}>
No thanks
</Button>
</InlineLayout>
</BlockStack>
);
}

function getRecommendedOffer(initialPurchase: any) {
// In production, this calls your recommendation engine
// For this example, return a static offer
return {
variantId: 'gid://shopify/ProductVariant/99999',
title: 'Premium Shoe Care Kit',
description: 'Keep your new shoes looking fresh with our complete care kit.',
imageUrl: 'https://example.com/shoe-care.jpg',
originalPrice: 29.99,
discountedPrice: 19.99,
discountPercentage: 33,
};
}
Post-Purchase Billing

购后追加销售会立即向客户的支付方式收费,无需客户重新输入卡信息。 This is powerful but must be used responsibly. Always clearly communicate the additional charge and make the decline option prominent. Abusing post-purchase extensions will result in app removal from the Shopify App Store.

支付自定义

支付自定义扩展允许你根据购物车内容、客户身份或其他条件,修改可用的支付方式及其显示顺序。

// extensions/payment-customization/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';

export function run(input: RunInput): FunctionRunResult {
const cart = input.cart;
const buyerCountry = input.buyerIdentity?.countryCode;

const operations: FunctionRunResult['operations'] = [];

// Hide Cash on Delivery for international orders
if (buyerCountry && buyerCountry !== 'US') {
const codMethod = input.paymentMethods.find(
(method) => method.name.includes('Cash on Delivery')
);
if (codMethod) {
operations.push({
hide: {
paymentMethodId: codMethod.id,
},
});
}
}

// Move PayPal to the top for carts over $200
const cartTotal = parseFloat(cart.cost.totalAmount.amount);
if (cartTotal > 200) {
const paypal = input.paymentMethods.find(
(method) => method.name.includes('PayPal')
);
if (paypal) {
operations.push({
move: {
paymentMethodId: paypal.id,
index: 0,
},
});
}
}

return { operations };
}

运费自定义

运费自定义扩展修改可用的运费选项——根据条件重命名、重新排序或隐藏运费。

// extensions/shipping-customization/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';

export function run(input: RunInput): FunctionRunResult {
const cart = input.cart;
const operations: FunctionRunResult['operations'] = [];

const cartTotal = parseFloat(cart.cost.totalAmount.amount);
const hasFragileItem = cart.lines.some((line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.hasAnyTag &&
line.merchandise.product.tags?.includes('fragile')
);

// Rename standard shipping to show estimated date
const standardRate = input.shippingRates.find(
(rate) => rate.title === 'Standard Shipping'
);
if (standardRate) {
operations.push({
rename: {
shippingRateId: standardRate.id,
title: 'Standard Shipping (5-7 business days)',
},
});
}

// Hide economy shipping for fragile items
if (hasFragileItem) {
const economyRate = input.shippingRates.find(
(rate) => rate.title.includes('Economy')
);
if (economyRate) {
operations.push({
hide: {
shippingRateId: economyRate.id,
},
});
}
}

// Free shipping for orders over $100
if (cartTotal >= 100) {
const standardShipping = input.shippingRates.find(
(rate) => rate.title.includes('Standard')
);
if (standardShipping) {
operations.push({
rename: {
shippingRateId: standardShipping.id,
title: 'Free Standard Shipping (5-7 business days)',
},
});
}
}

return { operations };
}

购物车和结账验证

验证扩展在结账时执行业务规则,防止无效订单被提交。

// extensions/cart-validation/src/run.ts
import type { RunInput, FunctionRunResult } from '../generated/api';

export function run(input: RunInput): FunctionRunResult {
const errors: FunctionRunResult['errors'] = [];
const cart = input.cart;

// Rule 1: Maximum 5 units of any single product
for (const line of cart.lines) {
if (line.quantity > 5) {
const title = line.merchandise.__typename === 'ProductVariant'
? line.merchandise.product.title
: 'this item';
errors.push({
localizedMessage: `Maximum 5 units of ${title} per order. Please reduce the quantity.`,
target: 'cart',
});
}
}

// Rule 2: Certain products cannot be shipped to PO Boxes
const shippingAddress = input.buyerIdentity?.shippingAddress;
const hasRestrictedItem = cart.lines.some(
(line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.tags?.includes('no-po-box')
);

if (
hasRestrictedItem &&
shippingAddress?.address1 &&
/p\.?o\.?\s*box/i.test(shippingAddress.address1)
) {
errors.push({
localizedMessage:
'One or more items in your cart cannot be shipped to a PO Box. Please use a street address.',
target: 'cart',
});
}

// Rule 3: Minimum order value for wholesale tagged customers
const isWholesale = input.buyerIdentity?.customer?.hasAnyTag &&
input.buyerIdentity.customer.tags?.includes('wholesale');
const cartTotal = parseFloat(cart.cost.totalAmount.amount);

if (isWholesale && cartTotal < 250) {
errors.push({
localizedMessage: `Wholesale accounts have a minimum order of $250.00. Your current total is $${cartTotal.toFixed(2)}.`,
target: 'cart',
});
}

return { errors };
}
Validation Function Limits

购物车验证函数在每次结账更新时执行 -- when items are added, addresses change, or discount codes are applied. They must complete within 5 seconds and cannot make external network requests. All data must come from the function input. If you need external data, use metafields to store it ahead of time.

部署和测试

# Test checkout extensions locally
shopify app dev

# The CLI provides a preview URL for the checkout:
# https://your-dev-store.myshopify.com/?preview_checkout_extensions=true

# Deploy to production
shopify app deploy

测试检查清单

  • Test with various cart compositions (single item, multiple items, high value, low value)
  • Test with different customer states (logged in, guest, returning customer)
  • Test on mobile devices (checkout is heavily used on mobile)
  • Test error states (network failures, invalid data)
  • Test with discount codes and gift cards applied
  • Verify that the checkout still completes if your extension fails to load
下一步

继续学习主题应用扩展,了解如何使用 Online Store 2.0 应用区块将你的应用功能添加到商家店面。