Skip to main content

POS UI 扩展

Shopify 销售点(POS)是在线店面的线下对应产品。POS UI 扩展允许你将自定义功能直接嵌入到店员在 iPad 和 iPhone 上进行线下销售时使用的 POS 应用中。 这为桥接线上和线下商务的应用打开了一个强大的渠道——忠诚度计划、客户关系管理工具、库存查询和自定义工作流都可以在销售点访问。

POS 要求

POS UI 扩展需要 Shopify POS Pro,它包含在 Shopify Plus 中或可作为附加功能使用。POS 应用在 iOS 设备(iPad 和 iPhone)上运行。扩展使用 Shopify 的远程 UI 框架渲染,类似于结账和管理后台扩展。

POS 扩展目标

POS 扩展可以在 POS 应用内的多个位置渲染:

TargetLocationDescription
pos.home.tile.renderSmart GridA tile on the POS home screen
pos.home.modal.renderFull-screen modalModal opened from a Smart Grid tile
pos.product-details.action.renderProduct detail actionAction on the product detail view
pos.product-details.block.renderProduct detail blockEmbedded block on product details
pos.order-details.action.renderOrder detail actionAction on the order detail view
pos.order-details.block.renderOrder detail blockEmbedded block on order details
pos.customer-details.action.renderCustomer detail actionAction on customer profiles
pos.customer-details.block.renderCustomer detail blockEmbedded block on customer profile
pos.checkout.action.renderCart actionAction during the checkout flow
pos.purchase.post.action.renderPost-purchaseAction 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}
/>
);
}
Smart Grid 磁贴设计

保持磁贴标签简短——店员在客户互动之间快速扫视它们。使用清晰的单词标题和简短的副标题。磁贴应该让人一眼就能理解其用途。避免使用员工可能不理解的行话或技术术语。

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 扩展可以通过 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

测试检查清单

  1. Test on both iPad and iPhone -- Screen sizes differ significantly
  2. Test with actual hardware -- Receipt printers and scanners behave differently than mocks
  3. Test offline scenarios -- Disconnect Wi-Fi and verify graceful degradation
  4. Test during active sales -- Extensions should not slow down the checkout flow
  5. Test with multiple staff members -- Verify session isolation between POS users
  6. Test the dismissal flow -- Ensure modals close cleanly and do not leave stale state
下一步

继续学习 Web Pixel 扩展,了解如何为 Shopify 店面实现隐私合规的客户事件追踪和分析。