POS UI Extensions
Shopify POS(Point of Sale)는 온라인 스토어프론트의 오프라인 매장 대응입니다. POS UI 확장을 사용하면 매장 직원이 대면 판매 중 iPad와 iPhone에서 사용하는 POS 앱에 커스텀 기능을 직접 삽입할 수 있습니다. This opens a powerful channel for apps that bridge online and offline commerce -- loyalty programs, clienteling tools, inventory lookup, and custom workflows all become accessible at the point of sale.
POS UI extensions require Shopify POS Pro, which is included with Shopify Plus or available as an add-on. The POS app runs on iOS devices (iPad and iPhone). Extensions are rendered using Shopify's remote UI framework, similar to checkout and admin extensions.
POS 확장 대상
POS extensions can render at several locations within the POS app:
| 대상 | 위치 | 설명 |
|---|---|---|
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 홈 화면으로, 매장 직원이 자주 사용하는 기능에 빠르게 접근하기 위해 사용하는 타일 그리드입니다. Your app can add tiles to this grid.
// 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}
/>
);
}
Keep tile labels short -- store associates glance at them quickly between customer interactions. Use a clear, single-word title and a brief subtitle. The tile should communicate its purpose at a glance. Avoid jargon or technical terms that staff may not understand.
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>
);
}
Retail environments often have unreliable network connections. POS extensions must handle offline scenarios gracefully. Cache critical data locally, queue actions that require network access, and always show clear feedback when the network is unavailable. A POS extension that freezes on a network timeout will disrupt the checkout line.
하드웨어 연동
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>
);
}
Hardware devices can be disconnected, out of paper, or malfunctioning. Never assume hardware operations will succeed. Always wrap hardware calls in try/catch blocks and provide a fallback (like showing information on screen instead of printing). The POS should never block a sale because of a hardware failure in your extension.
Dev MCP를 활용한 POS 확장 구축
Shopify's Dev MCP (Model Context Protocol) server can accelerate POS extension development by generating boilerplate code, suggesting extension targets, and helping debug issues.
# 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"
The Dev MCP server provides context about:
- Available POS extension targets and their APIs
- POS-specific UI components and their props
- Hardware API capabilities and limitations
- Best practices for retail workflows
POS 확장 테스트
POS extensions cannot be tested in a browser. You must use the Shopify POS app on a physical iOS device or the POS development simulator.
# 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
Continue to Web Pixel Extensions to learn how to implement privacy-compliant customer event tracking and analytics for Shopify storefronts.