Skip to main content

POS UI 擴充功能

Shopify Point of Sale (POS) 是線上店面的實體店對應產品。POS UI 擴充功能讓您將自訂功能直接嵌入店員在 iPad 和 iPhone 上使用的 POS 應用程式中,用於現場銷售。這為連接線上和線下商務的應用程式開啟了強大的通道——忠誠度計畫、客戶關係工具、庫存查詢和自訂工作流程都可以在銷售點存取。

POS 需求

POS UI 擴充功能需要 Shopify POS Pro,這包含在 Shopify Plus 中或可作為附加功能購買。POS 應用程式在 iOS 裝置(iPad 和 iPhone)上執行。擴充功能使用 Shopify 的遠端 UI 框架渲染,類似於結帳和管理後台擴充功能。

POS 擴充功能目標

POS 擴充功能可在 POS 應用程式中的多個位置渲染:

目標位置描述
pos.home.tile.renderSmart GridPOS 主畫面上的圖塊
pos.home.modal.render全螢幕模態從 Smart Grid 圖塊開啟的模態
pos.product-details.action.render產品詳情操作產品詳情檢視上的操作
pos.product-details.block.render產品詳情區塊產品詳情上的嵌入區塊
pos.order-details.action.render訂單詳情操作訂單詳情檢視上的操作
pos.order-details.block.render訂單詳情區塊訂單詳情上的嵌入區塊
pos.customer-details.action.render顧客詳情操作顧客個人資料上的操作
pos.customer-details.block.render顧客詳情區塊顧客個人資料上的嵌入區塊
pos.checkout.action.render購物車操作結帳流程中的操作
pos.purchase.post.action.render購後銷售完成後的操作

建立 POS 擴充功能

# 產生 POS UI 擴充功能
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="忠誠度"
subtitle="查詢點數"
onPress={() => {
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="忠誠度查詢">
<CustomerSearchScreen />
</Screen>
<Screen name="CustomerDetail" title="忠誠度詳情">
<CustomerDetailScreen />
</Screen>
<Screen name="RedeemPoints" title="兌換點數">
<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="依姓名、電子郵件或電話搜尋..."
value={searchQuery}
onTextChange={handleSearch}
onSearch={handleSearch}
/>

{results.length > 0 && (
<Section title={`找到 ${results.length} 位會員`}>
{results.map((customer) => (
<Row
key={customer.id}
title={customer.name}
subtitle={`${customer.pointsBalance.toLocaleString()} 點數 | ${customer.tier} 等級`}
onPress={() => {
api.navigator.navigate('CustomerDetail', {
customerId: customer.id,
});
}}
/>
))}
</Section>
)}

{searchQuery.length >= 2 && results.length === 0 && !searching && (
<Banner
title="未找到會員"
variant="information"
action="註冊新會員"
onPress={() => {}}
/>
)}
</ScrollView>
);
}

function CustomerDetailScreen() {
const api = useApi<'pos.home.modal.render'>();
const [customer, setCustomer] = useState<any>(null);

return (
<ScrollView>
<Section title="忠誠度狀態">
<Row
title="點數餘額"
rightSide={
<Text variant="headingLarge">
{customer?.pointsBalance?.toLocaleString() ?? '---'}
</Text>
}
/>
<Row
title="等級"
rightSide={<Text>{customer?.tier ?? '---'}</Text>}
/>
<Row
title="加入日期"
rightSide={
<Text>
{customer?.enrolledAt
? new Date(customer.enrolledAt).toLocaleDateString()
: '---'}
</Text>
}
/>
</Section>

<Section title="近期活動">
{(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="兌換點數"
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({
points: pointsToRedeem,
}),
});

if (response.ok) {
const result = await response.json();
await api.cart.applyCartDiscount(
'fixedAmount',
result.dollarValue.toString(),
'忠誠點數兌換'
);
api.navigator.dismiss();
}
} catch (err) {
console.error('Redemption failed:', err);
} finally {
setProcessing(false);
}
}

return (
<ScrollView>
<Section title="兌換點數">
<Row title="兌換點數" subtitle="100 點 = $1.00" />
<Button
title={processing ? '處理中...' : '套用到購物車'}
type="primary"
onPress={handleRedeem}
disabled={processing || pointsToRedeem === 0}
/>
</Section>
</ScrollView>
);
}
POS 網路可靠性

零售環境通常有不穩定的網路連線。POS 擴充功能必須優雅地處理離線場景。在本機快取關鍵資料、將需要網路存取的操作排隊,並在網路不可用時始終顯示清晰的回饋。在網路逾時時凍結的 POS 擴充功能會中斷結帳排隊。

硬體整合

POS 擴充功能可以透過 POS Hardware API 與連接的硬體互動。

收據印表機

// 列印自訂忠誠度收據
async function printLoyaltyReceipt(api: any, customer: any) {
const receiptContent = {
body: [
{ type: 'text', content: '忠誠度計畫收據', bold: true, align: 'center' },
{ type: 'line' },
{ type: 'text', content: `會員:${customer.name}` },
{ type: 'text', content: `等級:${customer.tier}` },
{ type: 'text', content: `點數餘額:${customer.pointsBalance.toLocaleString()}` },
{ type: 'line' },
{ type: 'text', content: `今日獲得點數:+${customer.pointsEarnedToday}` },
{ type: 'text', content: `新餘額:${customer.newBalance.toLocaleString()}` },
{ type: 'line' },
{
type: 'barcode',
content: customer.loyaltyBarcode,
format: 'CODE128',
},
{ type: 'text', content: '掃描以線上查看您的餘額', align: 'center', size: 'small' },
],
};

try {
await api.hardware.printer.print(receiptContent);
} catch (err) {
console.error('Failed to print receipt:', err);
}
}

條碼掃描器

// 監聽條碼掃描以查詢忠誠度會員
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 }) => {
if (event.data.startsWith('LYL-')) {
onScan(event.data);
}
}
);

return () => unsubscribe();
}, [api, onScan]);
}

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="掃描忠誠卡"
variant="information"
/>
{customer && (
<Section title="已找到會員">
<Row title={customer.name} subtitle={`${customer.pointsBalance} 點數`} />
</Section>
)}
</ScrollView>
);
}
硬體錯誤處理

硬體裝置可能斷開連接、缺紙或故障。永遠不要假設硬體操作會成功。始終將硬體呼叫包裹在 try/catch 區塊中,並提供備用方案(例如在螢幕上顯示資訊而不是列印)。POS 不應該因為您的擴充功能中的硬體故障而阻擋銷售。

使用 Dev MCP 建構 POS 擴充功能

Shopify 的 Dev MCP(Model Context Protocol)伺服器可以透過產生樣板程式碼、建議擴充功能目標和協助除錯問題來加速 POS 擴充功能開發。

# 使用 Claude Code 搭配 Shopify Dev MCP 來建構 POS 擴充功能骨架
# MCP 伺服器理解 POS 特定的模式

# Claude Code 搭配 Dev MCP 的範例提示:
# "建立一個 POS 擴充功能,當店員查看顧客個人資料時
# 顯示顧客購買歷史"

Dev MCP 伺服器提供以下上下文:

  • 可用的 POS 擴充功能目標及其 API
  • POS 特定的 UI 元件及其 props
  • Hardware API 功能和限制
  • 零售工作流程的最佳實務

測試 POS 擴充功能

POS 擴充功能無法在瀏覽器中測試。您必須使用實體 iOS 裝置上的 Shopify POS 應用程式或 POS 開發模擬器。

# 啟動 POS 擴充功能的開發伺服器
shopify app dev --tunnel

# CLI 將顯示 QR 碼以連接 POS 應用程式
# 使用 iOS 裝置上的 Shopify POS 應用程式掃描

測試檢查清單

  1. 在 iPad 和 iPhone 上測試——螢幕尺寸差異顯著
  2. 使用實際硬體測試——收據印表機和掃描器的行為與模擬不同
  3. 測試離線場景——斷開 Wi-Fi 並驗證優雅降級
  4. 在活躍銷售期間測試——擴充功能不應減慢結帳流程
  5. 使用多位員工測試——驗證 POS 使用者之間的工作階段隔離
  6. 測試關閉流程——確保模態乾淨地關閉且不會留下過期狀態
下一步

繼續閱讀 Web Pixel 擴充功能 以了解如何為 Shopify 店面實作符合隱私規範的顧客事件追蹤和分析。