POS UI 扩展
Shopify 销售点(POS)是在线店面的线下对应产品。POS UI 扩展允许你将自定义功能直接嵌入到店员在 iPad 和 iPhone 上进行线下销售时使用的 POS 应用中。 这为桥接线上和线下商务的应用打开了一个强大的渠道——忠诚度计划、客户关系管理工具、库存查询和自定义工作流都可以在销售点访问。
POS UI 扩展需要 Shopify POS Pro,它包含在 Shopify Plus 中或可作为附加功能使用。POS 应用在 iOS 设备(iPad 和 iPhone)上运行。扩展使用 Shopify 的远程 UI 框架渲染,类似于结账和管理后台扩展。
POS 扩展目标
POS 扩展可以在 POS 应用内的多个位置渲染:
| Target | Location | Description |
|---|---|---|
pos.home.tile.render | Smart Grid | A tile on the POS home screen |
pos.home.modal.render | Full-screen modal | Modal opened from a Smart Grid tile |
pos.product-details.action.render | Product detail action | Action on the product detail view |
pos.product-details.block.render | Product detail block | Embedded block on product details |
pos.order-details.action.render | Order detail action | Action on the order detail view |
pos.order-details.block.render | Order detail block | Embedded block on order details |
pos.customer-details.action.render | Customer detail action | Action on customer profiles |
pos.customer-details.block.render | Customer detail block | Embedded block on customer profile |
pos.checkout.action.render | Cart action | Action during the checkout flow |
pos.purchase.post.action.render | Post-purchase | Action after sale is completed |
创建 POS 扩展
# Generate a POS UI extension
shopify app generate extension --template pos_ui --name loyalty-pos
扩展配置
# extensions/loyalty-pos/shopify.extension.toml
api_version = "2025-01"
[[extensions]]
type = "pos_ui_extension"
name = "Loyalty Program"
handle = "loyalty-pos"
description = "Look up and manage customer loyalty points at the point of sale"
[[extensions.targeting]]
target = "pos.home.tile.render"
module = "./src/SmartGridTile.tsx"
[[extensions.targeting]]
target = "pos.home.modal.render"
module = "./src/LoyaltyModal.tsx"
[[extensions.targeting]]
target = "pos.customer-details.block.render"
module = "./src/CustomerLoyaltyBlock.tsx"
[[extensions.targeting]]
target = "pos.purchase.post.action.render"
module = "./src/PostPurchasePoints.tsx"
Smart Grid 磁贴
Smart Grid 是 POS 主屏幕——一个磁贴网格,店员用它来快速访问常用功能。你的应用可以向此网格添加磁贴。
// extensions/loyalty-pos/src/SmartGridTile.tsx
import {
reactExtension,
Tile,
useApi,
Text,
Icon,
} from '@shopify/ui-extensions-react/point-of-sale';
const smartGridTile = reactExtension(
'pos.home.tile.render',
() => <LoyaltyTile />
);
export default smartGridTile;
function LoyaltyTile() {
const api = useApi<'pos.home.tile.render'>();
return (
<Tile
title="Loyalty"
subtitle="Look up points"
onPress={() => {
// Opens the modal registered for pos.home.modal.render
api.smartGrid.presentModal();
}}
enabled={true}
/>
);
}
保持磁贴标签简短——店员在客户互动之间快速扫视它们。使用清晰的单词标题和简短的副标题。磁贴应该让人一眼就能理解其用途。避免使用员工可能不理解的行话或技术术语。
POS 模态扩展
当店员点击你的 Smart Grid 磁贴时,会打开一个全屏模态窗口。这是你的主要 POS 功能所在。
// extensions/loyalty-pos/src/LoyaltyModal.tsx
import { useState, useCallback } from 'react';
import {
reactExtension,
Screen,
ScrollView,
Navigator,
SearchBar,
Section,
Row,
Text,
Button,
Banner,
useApi,
} from '@shopify/ui-extensions-react/point-of-sale';
const loyaltyModal = reactExtension(
'pos.home.modal.render',
() => <LoyaltyModal />
);
export default loyaltyModal;
function LoyaltyModal() {
return (
<Navigator>
<Screen name="CustomerSearch" title="Loyalty Lookup">
<CustomerSearchScreen />
</Screen>
<Screen name="CustomerDetail" title="Loyalty Details">
<CustomerDetailScreen />
</Screen>
<Screen name="RedeemPoints" title="Redeem Points">
<RedeemPointsScreen />
</Screen>
</Navigator>
);
}
function CustomerSearchScreen() {
const api = useApi<'pos.home.modal.render'>();
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
const [searching, setSearching] = useState(false);
const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query);
if (query.length < 2) {
setResults([]);
return;
}
setSearching(true);
try {
const response = await fetch(
`/api/loyalty/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setResults(data.customers);
} catch (err) {
console.error('Search failed:', err);
} finally {
setSearching(false);
}
}, []);
return (
<ScrollView>
<SearchBar
placeholder="Search by name, email, or phone..."
value={searchQuery}
onTextChange={handleSearch}
onSearch={handleSearch}
/>
{results.length > 0 && (
<Section title={`${results.length} members found`}>
{results.map((customer) => (
<Row
key={customer.id}
title={customer.name}
subtitle={`${customer.pointsBalance.toLocaleString()} points | ${customer.tier} tier`}
onPress={() => {
api.navigator.navigate('CustomerDetail', {
customerId: customer.id,
});
}}
/>
))}
</Section>
)}
{searchQuery.length >= 2 && results.length === 0 && !searching && (
<Banner
title="No members found"
variant="information"
action="Enroll New Member"
onPress={() => {
// Navigate to enrollment screen
}}
/>
)}
</ScrollView>
);
}
function CustomerDetailScreen() {
const api = useApi<'pos.home.modal.render'>();
const [customer, setCustomer] = useState<any>(null);
// In a real implementation, fetch customer data based on navigation params
return (
<ScrollView>
<Section title="Loyalty Status">
<Row
title="Points Balance"
rightSide={
<Text variant="headingLarge">
{customer?.pointsBalance?.toLocaleString() ?? '---'}
</Text>
}
/>
<Row
title="Tier"
rightSide={<Text>{customer?.tier ?? '---'}</Text>}
/>
<Row
title="Member Since"
rightSide={
<Text>
{customer?.enrolledAt
? new Date(customer.enrolledAt).toLocaleDateString()
: '---'}
</Text>
}
/>
</Section>
<Section title="Recent Activity">
{(customer?.recentActivity ?? []).map((activity: any) => (
<Row
key={activity.id}
title={activity.description}
subtitle={new Date(activity.date).toLocaleDateString()}
rightSide={
<Text tone={activity.type === 'earned' ? 'success' : 'warning'}>
{activity.type === 'earned' ? '+' : '-'}
{activity.points}
</Text>
}
/>
))}
</Section>
<Button
title="Redeem Points"
type="primary"
onPress={() => {
api.navigator.navigate('RedeemPoints', {
customerId: customer?.id,
});
}}
/>
</ScrollView>
);
}
function RedeemPointsScreen() {
const api = useApi<'pos.home.modal.render'>();
const [pointsToRedeem, setPointsToRedeem] = useState(0);
const [processing, setProcessing] = useState(false);
async function handleRedeem() {
setProcessing(true);
try {
const response = await fetch('/api/loyalty/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// customerId from navigation params
points: pointsToRedeem,
}),
});
if (response.ok) {
const result = await response.json();
// Apply discount to current cart
await api.cart.applyCartDiscount(
'fixedAmount',
result.dollarValue.toString(),
'Loyalty Points Redemption'
);
api.navigator.dismiss();
}
} catch (err) {
console.error('Redemption failed:', err);
} finally {
setProcessing(false);
}
}
return (
<ScrollView>
<Section title="Redeem Points">
<Row title="Points to redeem" subtitle="100 points = $1.00" />
{/* Point selection UI */}
<Button
title={processing ? 'Processing...' : 'Apply to Cart'}
type="primary"
onPress={handleRedeem}
disabled={processing || pointsToRedeem === 0}
/>
</Section>
</ScrollView>
);
}
零售环境中经常有不稳定的网络连接。POS 扩展必须优雅地处理离线场景。在本地缓存关键数据,将需要网络访问的操作排队,并在网络不可用时始终显示清晰的反馈。在网络超时时冻结的 POS 扩展会打断结账排队。
硬件集成
POS 扩展可以通过 POS Hardware API 与连接的硬件进行交互。
收据打印机
// Print a custom loyalty receipt
async function printLoyaltyReceipt(api: any, customer: any) {
const receiptContent = {
body: [
{ type: 'text', content: 'LOYALTY PROGRAM RECEIPT', bold: true, align: 'center' },
{ type: 'line' },
{ type: 'text', content: `Member: ${customer.name}` },
{ type: 'text', content: `Tier: ${customer.tier}` },
{ type: 'text', content: `Points Balance: ${customer.pointsBalance.toLocaleString()}` },
{ type: 'line' },
{ type: 'text', content: `Points Earned Today: +${customer.pointsEarnedToday}` },
{ type: 'text', content: `New Balance: ${customer.newBalance.toLocaleString()}` },
{ type: 'line' },
{
type: 'barcode',
content: customer.loyaltyBarcode,
format: 'CODE128',
},
{ type: 'text', content: 'Scan to check your balance online', align: 'center', size: 'small' },
],
};
try {
await api.hardware.printer.print(receiptContent);
} catch (err) {
console.error('Failed to print receipt:', err);
// Fall back to showing the info on screen
}
}
条码扫描器
// Listen for barcode scans to look up loyalty members
function useBarcodeScanner(onScan: (barcode: string) => void) {
const api = useApi<'pos.home.modal.render'>();
useEffect(() => {
const unsubscribe = api.hardware.scanner.subscribe(
(event: { data: string; type: string }) => {
// Filter for loyalty card barcodes (they start with "LYL-")
if (event.data.startsWith('LYL-')) {
onScan(event.data);
}
}
);
return () => unsubscribe();
}, [api, onScan]);
}
// Usage in a component
function ScannerEnabledSearch() {
const [customer, setCustomer] = useState(null);
useBarcodeScanner(async (barcode) => {
const response = await fetch(
`/api/loyalty/lookup?barcode=${encodeURIComponent(barcode)}`
);
const data = await response.json();
setCustomer(data.customer);
});
return (
<ScrollView>
<Banner
title="Scan a loyalty card"
variant="information"
/>
{customer && (
<Section title="Member Found">
<Row title={customer.name} subtitle={`${customer.pointsBalance} points`} />
</Section>
)}
</ScrollView>
);
}
硬件设备可能断开连接、缺纸或出现故障。永远不要假设硬件操作会成功。始终将硬件调用包装在 try/catch 块中,并提供回退方案(如在屏幕上显示信息而非打印)。POS 永远不应因为扩展中的硬件故障而阻止销售。
使用 Dev MCP 构建 POS 扩展
Shopify 的 Dev MCP(Model Context Protocol)服务器可以通过生成样板代码、建议扩展目标和帮助调试问题来加速 POS 扩展开发。
# Use Claude Code with Shopify Dev MCP to scaffold a POS extension
# The MCP server understands POS-specific patterns
# Example prompt to Claude Code with Dev MCP:
# "Create a POS extension that shows customer purchase history
# when a store associate views a customer profile"
Dev MCP 服务器提供以下方面的上下文:
- 可用的 POS 扩展目标及其 API
- POS 专用 UI 组件及其属性
- 硬件 API 的功能和限制
- 零售工作流的最佳实践
测试 POS 扩展
POS 扩展无法在浏览器中测试。你必须使用物理 iOS 设备上的 Shopify POS 应用或 POS 开发模拟器。
# Start development server for POS extensions
shopify app dev --tunnel
# The CLI will display a QR code to connect the POS app
# Scan it with the Shopify POS app on your iOS device
测试检查清单
- Test on both iPad and iPhone -- Screen sizes differ significantly
- Test with actual hardware -- Receipt printers and scanners behave differently than mocks
- Test offline scenarios -- Disconnect Wi-Fi and verify graceful degradation
- Test during active sales -- Extensions should not slow down the checkout flow
- Test with multiple staff members -- Verify session isolation between POS users
- Test the dismissal flow -- Ensure modals close cleanly and do not leave stale state
继续学习 Web Pixel 扩展,了解如何为 Shopify 店面实现隐私合规的客户事件追踪和分析。