Skip to main content

POS UI エクステンション

Shopify Point of Sale(POS)はオンラインストアフロントの店舗版です。POS UI エクステンションを使用すると、店舗スタッフが iPad や iPhone で対面販売時に使用する POS アプリにカスタム機能を直接埋め込むことができます。これにより、オンラインとオフラインのコマースを橋渡しするアプリの強力なチャネルが開かれます -- ロイヤルティプログラム、接客ツール、在庫検索、カスタムワークフローがすべて POS で利用可能になります。

POS の要件

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.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 タイルのデザイン

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>
);
}
POS ネットワークの信頼性

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 extensions can interact with connected hardware through the 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

テストチェックリスト

  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
次のステップ

Continue to Web Pixel Extensions to learn how to implement privacy-compliant customer event tracking and analytics for Shopify storefronts.