Web-Based POS 系統開發
POS 系統架構設計
💡 比喻:數位化的收銀台 傳統的收銀台有:收銀機、商品標籤、計算機、收據紙。 Web-Based POS 就是把這些全部搬到瀏覽器裡:
- 收銀機 → Web 畫面上的購物車
- 商品標籤 → 資料庫裡的商品資料
- 計算機 → JavaScript 自動計算
- 收據紙 → 透過 Print Agent 列印
系統架構圖
Web-Based POS 系統架構:
使用者(店員)
│
▼
┌──────────────┐
│ Chromium │ ← 前端(HTML/CSS/JS)
│ Kiosk Mode │ ← 觸控螢幕操作
└──────┬───────┘
│ HTTP / WebSocket
▼
┌──────────────┐
│ ASP.NET │ ← 後端 API
│ Core API │ ← 商品/訂單/庫存管理
└──────┬───────┘
│
┌────┴────┐
▼ ▼
┌──────┐ ┌──────────┐
│ SQLite│ │ Print │
│ 資料庫│ │ Agent │
└──────┘ └──────────┘
前端:HTML/CSS/JS 收銀畫面
💡 比喻:店員面前的收銀螢幕 收銀畫面就是店員每天面對的「工作台」。 左邊是商品按鈕(快速點選),右邊是購物車(已選商品)。 設計時要注意:按鈕要夠大(觸控螢幕用手指按)、字要清楚。
POS 前端 HTML 結構
<!-- POS 收銀畫面的 HTML 結構 -->
<!-- 主要分成左右兩個區域 -->
<!DOCTYPE html>
<html lang="zh-TW"> <!-- 設定語言為繁體中文 -->
<head>
<meta charset="UTF-8"> <!-- 字元編碼 UTF-8 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- 響應式設計 -->
<title>POS 收銀系統</title> <!-- 頁面標題 -->
<style>
/* POS 系統的基本樣式 */
* { margin: 0; padding: 0; box-sizing: border-box; } /* 重設邊距 */
body { font-family: 'Noto Sans TC', sans-serif; } /* 使用中文字型 */
.pos-container { /* POS 主容器 */
display: grid; /* 使用 Grid 排版 */
grid-template-columns: 1fr 400px; /* 左寬右窄 */
height: 100vh; /* 滿版高度 */
}
.product-grid { /* 商品區域 */
display: grid; /* 商品用 Grid 排列 */
grid-template-columns: repeat(4, 1fr); /* 每排 4 個 */
gap: 10px; /* 間距 10px */
padding: 20px; /* 內距 20px */
overflow-y: auto; /* 超出時可捲動 */
}
.product-btn { /* 商品按鈕 */
height: 100px; /* 按鈕高度 100px */
font-size: 18px; /* 字體大小 18px */
border: 2px solid #ddd; /* 邊框樣式 */
border-radius: 8px; /* 圓角 */
cursor: pointer; /* 滑鼠游標 */
display: flex; /* Flex 排版 */
flex-direction: column; /* 垂直排列 */
align-items: center; /* 水平置中 */
justify-content: center; /* 垂直置中 */
}
.cart-panel { /* 購物車面板 */
background: #f5f5f5; /* 淺灰背景 */
padding: 20px; /* 內距 */
display: flex; /* Flex 排版 */
flex-direction: column; /* 垂直排列 */
}
</style>
</head>
<body>
<div class="pos-container"> <!-- POS 主容器開始 -->
<div class="product-grid" id="productGrid"> <!-- 商品區域 -->
<!-- 商品按鈕會由 JavaScript 動態產生 -->
</div>
<div class="cart-panel"> <!-- 購物車面板 -->
<h2>購物車</h2> <!-- 購物車標題 -->
<div id="cartItems" style="flex:1;overflow-y:auto;"> <!-- 購物車商品清單 -->
</div>
<div id="cartTotal" style="font-size:24px;font-weight:bold;padding:10px 0;"> <!-- 總計金額 -->
總計:$0
</div>
<button id="checkoutBtn" style="height:60px;font-size:20px;background:#4CAF50;color:white;border:none;border-radius:8px;"> <!-- 結帳按鈕 -->
結帳
</button>
</div>
</div>
</body>
</html>
商品管理(CRUD + 條碼掃描)
商品資料模型
// 定義商品資料的類別 // POS 系統最核心的資料
public class Product // 商品類別
{
public int Id { get; set; } // 商品編號 // 自動遞增的主鍵
public string Name { get; set; } = ""; // 商品名稱 // 例如「美式咖啡」
public string Barcode { get; set; } = ""; // 條碼 // EAN-13 或自訂條碼
public decimal Price { get; set; } // 售價 // 含稅價格
public int Stock { get; set; } // 庫存數量 // 即時庫存
public string Category { get; set; } = ""; // 商品分類 // 例如「飲料」、「餐點」
public bool IsActive { get; set; } = true; // 是否上架 // 可暫時下架
public string ImageUrl { get; set; } = ""; // 商品圖片網址 // 顯示在 POS 按鈕上
public DateTime CreatedAt { get; set; } = DateTime.Now; // 建立時間 // 自動記錄
}
// 商品管理服務的類別 // 處理商品的新增、查詢、修改、刪除
public class ProductService // 商品服務類別
{
private readonly List<Product> _products = new(); // 商品清單 // 模擬資料庫
// 新增商品的方法 // Create 操作
public Product AddProduct(string name, string barcode, decimal price, int stock) // 新增商品
{
var product = new Product // 建立商品物件
{
Id = _products.Count + 1, // 自動設定編號 // 簡單的遞增 ID
Name = name, // 設定名稱 // 商品顯示名稱
Barcode = barcode, // 設定條碼 // 用於掃描器讀取
Price = price, // 設定價格 // 銷售單價
Stock = stock // 設定庫存 // 初始庫存數量
}; // 商品物件建立完成
_products.Add(product); // 加入清單 // 儲存到資料庫
return product; // 回傳新建的商品 // 含自動產生的 ID
}
// 用條碼查詢商品的方法 // 掃描器掃到條碼後用這個查
public Product? FindByBarcode(string barcode) // 條碼查詢方法
{
return _products.FirstOrDefault(p => p.Barcode == barcode); // 找到第一個符合的商品 // 找不到回傳 null
}
// 更新庫存的方法 // 賣出商品後扣庫存
public bool UpdateStock(int productId, int quantitySold) // 更新庫存方法
{
var product = _products.FirstOrDefault(p => p.Id == productId); // 找到商品 // 用 ID 查詢
if (product == null) return false; // 商品不存在 // 回傳失敗
if (product.Stock < quantitySold) return false; // 庫存不足 // 回傳失敗
product.Stock -= quantitySold; // 扣除庫存 // 減去賣出數量
return true; // 回傳成功 // 庫存更新完成
}
}
購物車邏輯(加入/移除/數量/小計/總計)
💡 比喻:超市推車 購物車就像實體的超市推車: 可以放入商品、拿出商品、改數量。 最後結帳時算總金額。
購物車 JavaScript 實作
// POS 購物車類別 // 管理所有購物車操作
class PosCart { // 購物車類別定義
constructor() { // 建構函式 // 初始化購物車
this.items = []; // 購物車商品陣列 // 空陣列開始
}
// 加入商品的方法 // 點擊商品按鈕時呼叫
addItem(product) { // 加入商品方法
const existing = this.items.find(i => i.productId === product.id); // 檢查商品是否已在購物車 // 用商品 ID 比對
if (existing) { // 如果已存在
existing.quantity += 1; // 數量加 1 // 同商品累加
existing.subtotal = existing.quantity * existing.price; // 重新計算小計 // 數量 x 單價
} else { // 如果是新商品
this.items.push({ // 加入新商品到陣列
productId: product.id, // 商品 ID // 用於識別
name: product.name, // 商品名稱 // 顯示用
price: product.price, // 單價 // 用於計算
quantity: 1, // 初始數量為 1 // 第一次加入
subtotal: product.price // 小計等於單價 // 數量為 1 所以等於單價
}); // 新商品加入完成
}
this.render(); // 重新渲染畫面 // 更新顯示
}
// 移除商品的方法 // 從購物車移除指定商品
removeItem(productId) { // 移除商品方法
this.items = this.items.filter(i => i.productId !== productId); // 過濾掉指定商品 // 保留其他商品
this.render(); // 重新渲染畫面 // 更新顯示
}
// 修改數量的方法 // 加減按鈕用
updateQuantity(productId, delta) { // 修改數量方法
const item = this.items.find(i => i.productId === productId); // 找到指定商品 // 用 ID 查找
if (!item) return; // 找不到就跳過 // 防禦性程式設計
item.quantity += delta; // 加減數量 // delta 可以是 +1 或 -1
if (item.quantity <= 0) { // 如果數量變成 0 或負數
this.removeItem(productId); // 移除該商品 // 數量為 0 就不要了
return; // 結束方法 // 已經移除了
}
item.subtotal = item.quantity * item.price; // 重新計算小計 // 數量 x 單價
this.render(); // 重新渲染畫面 // 更新顯示
}
// 計算總金額的方法 // 所有商品小計加總
getTotal() { // 取得總計方法
return this.items.reduce((sum, item) => sum + item.subtotal, 0); // 加總所有小計 // reduce 累加
}
// 清空購物車的方法 // 結帳完成後呼叫
clear() { // 清空方法
this.items = []; // 清空陣列 // 重設為空
this.render(); // 重新渲染畫面 // 更新顯示
}
// 渲染購物車畫面的方法 // 更新 HTML 顯示
render() { // 渲染方法
const cartEl = document.getElementById('cartItems'); // 取得購物車容器 // DOM 元素
const totalEl = document.getElementById('cartTotal'); // 取得總計容器 // DOM 元素
cartEl.innerHTML = this.items.map(item => // 產生每個商品的 HTML // 用 map 轉換
`<div style="display:flex;justify-content:space-between;padding:8px;border-bottom:1px solid #ddd;">
<span>${item.name}</span>
<span>${item.quantity} x $${item.price} = $${item.subtotal}</span>
</div>` // 商品名稱、數量、小計 // 格式化顯示
).join(''); // 組合所有 HTML // 串接字串
totalEl.textContent = `總計:$${this.getTotal()}`; // 更新總計顯示 // 呼叫 getTotal()
}
}
// 初始化購物車 // 頁面載入時建立
const cart = new PosCart(); // 建立購物車實例 // 全域變數
付款流程(現金/信用卡/行動支付)
// 定義付款方式的列舉 // POS 支援的付款方式
public enum PaymentMethod // 付款方式列舉
{
Cash, // 現金 // 最傳統的付款方式
CreditCard, // 信用卡 // 刷卡付款
LinePay, // LINE Pay // 行動支付
JkoPay, // 街口支付 // 行動支付
EasyCard // 悠遊卡 // 電子票證
}
// 定義訂單的類別 // 一筆完整的交易記錄
public class Order // 訂單類別
{
public int Id { get; set; } // 訂單編號 // 自動遞增
public DateTime OrderTime { get; set; } = DateTime.Now; // 下單時間 // 自動記錄
public List<OrderItem> Items { get; set; } = new(); // 訂單明細 // 商品清單
public decimal TotalAmount { get; set; } // 總金額 // 所有商品加總
public PaymentMethod Payment { get; set; } // 付款方式 // 現金/信用卡/行動支付
public decimal ReceivedAmount { get; set; } // 收到金額 // 現金付款時使用
public decimal ChangeAmount { get; set; } // 找零金額 // 收到 - 總額
public string Status { get; set; } = "completed"; // 訂單狀態 // 完成/取消/退貨
}
// 定義訂單明細的類別 // 單一商品在訂單中的記錄
public class OrderItem // 訂單明細類別
{
public int ProductId { get; set; } // 商品編號 // 對應商品資料
public string ProductName { get; set; } = ""; // 商品名稱 // 冗餘儲存避免關聯查詢
public decimal UnitPrice { get; set; } // 單價 // 購買當下的價格
public int Quantity { get; set; } // 數量 // 購買數量
public decimal Subtotal { get; set; } // 小計 // 單價 x 數量
}
// 結帳服務的類別 // 處理付款流程
public class CheckoutService // 結帳服務
{
// 處理現金付款的方法 // 計算找零
public Order ProcessCashPayment(List<OrderItem> items, decimal receivedAmount) // 現金結帳方法
{
var total = items.Sum(i => i.Subtotal); // 計算總金額 // 加總所有小計
if (receivedAmount < total) // 檢查收到金額是否足夠
throw new InvalidOperationException( // 金額不足丟出例外
$"金額不足!應收 {total},實收 {receivedAmount}"); // 顯示差額
var order = new Order // 建立訂單物件
{
Items = items, // 設定訂單明細 // 所有商品
TotalAmount = total, // 設定總金額 // 加總結果
Payment = PaymentMethod.Cash, // 設定付款方式為現金
ReceivedAmount = receivedAmount, // 設定收到金額
ChangeAmount = receivedAmount - total // 計算找零 // 收到 - 總額
}; // 訂單建立完成
Console.WriteLine($"交易完成!總額:{total},收到:{receivedAmount},找零:{order.ChangeAmount}"); // 顯示交易結果
return order; // 回傳訂單 // 包含完整交易資訊
}
}
收據格式設計
// 定義收據產生器的類別 // 產生文字格式的收據
public class ReceiptGenerator // 收據產生器
{
private const int ReceiptWidth = 32; // 收據寬度(字元數)// 熱感應印表機通常 32 或 48 字元
// 產生收據的方法 // 回傳格式化的收據字串
public static string GenerateReceipt(Order order, string storeName) // 產生收據方法
{
var lines = new List<string>(); // 建立行清單 // 收據的每一行
lines.Add(CenterText(storeName)); // 店名置中 // 收據最上方
lines.Add(CenterText("統一編號:12345678")); // 統編置中 // 公司稅號
lines.Add(new string('-', ReceiptWidth)); // 分隔線 // 用虛線分隔
lines.Add($"日期:{order.OrderTime:yyyy/MM/dd HH:mm}"); // 交易日期時間 // 格式化日期
lines.Add($"單號:{order.Id:D6}"); // 訂單編號 // 6 位數補零
lines.Add(new string('-', ReceiptWidth)); // 分隔線
foreach (var item in order.Items) // 逐一列出商品
{
lines.Add($"{item.ProductName}"); // 商品名稱 // 獨立一行
lines.Add($" {item.Quantity} x ${item.UnitPrice} = ${item.Subtotal}"); // 數量 x 單價 = 小計
}
lines.Add(new string('=', ReceiptWidth)); // 雙線分隔 // 用等號
lines.Add($"總計:${order.TotalAmount}"); // 總金額 // 所有商品加總
lines.Add($"付款方式:{order.Payment}"); // 付款方式 // 現金/信用卡等
if (order.Payment == PaymentMethod.Cash) // 如果是現金付款
{
lines.Add($"收到:${order.ReceivedAmount}"); // 收到金額
lines.Add($"找零:${order.ChangeAmount}"); // 找零金額
}
lines.Add(new string('-', ReceiptWidth)); // 分隔線
lines.Add(CenterText("謝謝光臨!")); // 感謝語置中
return string.Join("\n", lines); // 組合所有行 // 用換行符號連接
}
// 文字置中的輔助方法 // 在收據寬度內置中顯示
private static string CenterText(string text) // 置中方法
{
var padding = (ReceiptWidth - text.Length) / 2; // 計算左邊空白數 // 讓文字置中
return text.PadLeft(padding + text.Length); // 左邊補空白 // 實現置中效果
}
}
Raspberry Pi Kiosk Mode(全螢幕 Web 模式)
# 設定 Pi 開機自動進入 Kiosk Mode // 自動啟動 POS 畫面
# 編輯自動啟動設定檔 // 開機後自動執行
# 步驟 1:建立自動啟動腳本 // 放在 home 目錄
cat << 'SCRIPT' > ~/start-pos.sh # 建立啟動腳本
#!/bin/bash # 使用 bash 執行
# 等待桌面環境準備好 // 避免太快啟動
sleep 5 # 等待 5 秒
# 停用螢幕保護程式 // POS 不需要螢幕保護
xset s off # 關閉螢幕保護
xset -dpms # 關閉電源管理
xset s noblank # 不要黑屏
# 啟動 Chromium Kiosk Mode // 全螢幕開啟 POS 系統
chromium-browser \
--kiosk \ # 全螢幕模式
--noerrdialogs \ # 不顯示錯誤對話框
--disable-infobars \ # 隱藏資訊列
--disable-translate \ # 停用翻譯功能
--no-first-run \ # 跳過首次執行提示
--start-fullscreen \ # 全螢幕啟動
http://localhost:5000 # POS 系統網址
SCRIPT
chmod +x ~/start-pos.sh # 設定執行權限
# 步驟 2:設定開機自動執行 // 加入 autostart
mkdir -p ~/.config/autostart # 建立 autostart 目錄
cat << 'DESKTOP' > ~/.config/autostart/pos-kiosk.desktop # 建立 desktop 檔案
[Desktop Entry] # Desktop 檔案格式
Type=Application # 類型為應用程式
Name=POS Kiosk # 名稱
Exec=/home/admin/start-pos.sh # 執行腳本路徑
DESKTOP
觸控螢幕操作優化
/* 觸控螢幕優化的 CSS 樣式 */
/* 讓按鈕更適合手指操作 */
/* 防止長按選取文字 */ /* 觸控螢幕常見問題 */
* {
-webkit-user-select: none; /* Safari/Chrome 防選取 */
user-select: none; /* 標準屬性 防選取 */
-webkit-touch-callout: none; /* iOS 防長按選單 */
-webkit-tap-highlight-color: transparent; /* 移除點擊高亮 */
}
/* 按鈕最小尺寸 */ /* 手指操作至少 44px */
.touch-btn {
min-width: 44px; /* 最小寬度 44px */
min-height: 44px; /* 最小高度 44px */
padding: 12px 16px; /* 內距加大 */
font-size: 16px; /* 字體不要太小 */
touch-action: manipulation; /* 優化觸控行為 */
}
/* 快速點擊回饋 */ /* 讓店員知道按到了 */
.touch-btn:active {
transform: scale(0.95); /* 按下時縮小 */
opacity: 0.8; /* 按下時半透明 */
transition: all 0.1s; /* 過渡動畫 0.1 秒 */
}
/* 數字鍵盤樣式 */ /* 輸入金額用 */
.numpad {
display: grid; /* Grid 排版 */
grid-template-columns: repeat(3, 1fr); /* 3 欄 */
gap: 8px; /* 間距 8px */
padding: 16px; /* 內距 16px */
}
.numpad button {
height: 64px; /* 按鈕高度 64px */
font-size: 24px; /* 字體大小 24px */
border-radius: 8px; /* 圓角 8px */
}
離線模式(Service Worker)
💡 比喻:停電時的備用發電機 如果網路斷了怎麼辦?Service Worker 就像備用發電機, 讓 POS 系統在沒有網路時也能繼續運作。 等網路恢復後,再把離線期間的交易資料同步回伺服器。
// Service Worker 註冊 // 在主頁面載入時執行
if ('serviceWorker' in navigator) { // 檢查瀏覽器是否支援 Service Worker
navigator.serviceWorker.register('/sw.js') // 註冊 Service Worker 檔案
.then(reg => console.log('SW 註冊成功', reg.scope)) // 註冊成功的回呼 // 顯示作用範圍
.catch(err => console.log('SW 註冊失敗', err)); // 註冊失敗的回呼 // 顯示錯誤
}
// sw.js - Service Worker 檔案 // 處理離線快取
const CACHE_NAME = 'pos-cache-v1'; // 快取名稱 // 版本號用於更新
const URLS_TO_CACHE = [ // 需要快取的檔案清單
'/', // 首頁 // POS 主畫面
'/css/pos.css', // CSS 樣式 // 畫面樣式
'/js/pos.js', // JavaScript // 購物車邏輯
'/js/cart.js', // 購物車 JS // 購物車類別
'/api/products', // 商品資料 API // 商品清單
]; // 快取清單結束
// 安裝事件 // Service Worker 第一次安裝時執行
self.addEventListener('install', event => { // 監聽安裝事件
event.waitUntil( // 等待快取完成
caches.open(CACHE_NAME) // 開啟快取空間
.then(cache => cache.addAll(URLS_TO_CACHE)) // 快取所有指定檔案
); // waitUntil 結束
}); // install 事件結束
// 攔截請求事件 // 決定從快取還是網路取得資料
self.addEventListener('fetch', event => { // 監聽網路請求
event.respondWith( // 回應請求
caches.match(event.request) // 先從快取找
.then(response => { // 快取查詢結果
if (response) return response; // 有快取就用快取 // 離線也能用
return fetch(event.request); // 沒快取就從網路取 // 正常連線時
}) // 查詢結束
); // respondWith 結束
}); // fetch 事件結束
離線交易暫存
// 離線交易暫存管理 // 網路斷線時暫存交易
class OfflineStore { // 離線暫存類別
constructor() { // 建構函式
this.dbName = 'pos-offline-db'; // IndexedDB 名稱 // 本地資料庫
}
// 儲存離線交易 // 存到 IndexedDB
async saveOfflineOrder(order) { // 儲存離線訂單方法
const orders = JSON.parse(localStorage.getItem('offlineOrders') || '[]'); // 讀取現有離線訂單 // 從 localStorage
orders.push({ ...order, offlineAt: new Date().toISOString() }); // 加入新訂單和時間戳
localStorage.setItem('offlineOrders', JSON.stringify(orders)); // 儲存回 localStorage
console.log(`離線訂單已暫存,共 ${orders.length} 筆待同步`); // 顯示暫存數量
}
// 同步離線交易 // 網路恢復時呼叫
async syncOfflineOrders() { // 同步方法
const orders = JSON.parse(localStorage.getItem('offlineOrders') || '[]'); // 讀取離線訂單
if (orders.length === 0) return; // 沒有離線訂單就跳過
console.log(`開始同步 ${orders.length} 筆離線訂單...`); // 顯示同步開始
for (const order of orders) { // 逐筆同步
try { // 嘗試同步
await fetch('/api/orders', { // 送出 API 請求
method: 'POST', // POST 方法
headers: { 'Content-Type': 'application/json' }, // JSON 格式
body: JSON.stringify(order) // 訂單資料
}); // 請求結束
console.log(`訂單同步成功:${order.id}`); // 同步成功
} catch (err) { // 同步失敗
console.error(`訂單同步失敗:${order.id}`, err); // 顯示錯誤
return; // 停止同步 // 等下次再試
}
}
localStorage.removeItem('offlineOrders'); // 清除已同步的離線訂單
console.log('所有離線訂單同步完成!'); // 顯示同步完成
}
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:購物車用浮點數計算金額
// ❌ 錯誤:直接用浮點數計算 // 會產生精度問題
let total = 0; // 初始化總計
total += 19.9; // 加一杯咖啡 // 19.9 元
total += 35.5; // 加一份三明治 // 35.5 元
console.log(total); // 55.400000000000006 ← 浮點數精度問題!
// ✅ 正確:用整數計算(分為單位)// 最後再除以 100
let totalCents = 0; // 用「分」為單位 // 避免浮點數問題
totalCents += 1990; // 19.9 元 = 1990 分
totalCents += 3550; // 35.5 元 = 3550 分
console.log(totalCents / 100); // 55.4 ← 正確!
// 或使用 toFixed // 簡單但不夠精確
console.log(parseFloat((19.9 + 35.5).toFixed(2))); // 55.4
❌ 錯誤 2:Service Worker 沒有處理更新
// ❌ 錯誤:永遠只用快取,不更新 // 商品價格改了也不知道
self.addEventListener('fetch', event => { // 監聽請求
event.respondWith(caches.match(event.request)); // 只看快取 // 永遠不會更新
}); // 這樣修改商品價格後 POS 還是顯示舊價格
// ✅ 正確:Cache-first + 背景更新 // 先用快取再更新
self.addEventListener('fetch', event => { // 監聯請求
event.respondWith( // 回應請求
caches.match(event.request).then(cached => { // 先查快取
const fetchPromise = fetch(event.request).then(response => { // 同時從網路取
caches.open(CACHE_NAME).then(cache => { // 開啟快取空間
cache.put(event.request, response.clone()); // 更新快取 // 下次就用新的
}); // 快取更新完成
return response; // 回傳網路結果
}); // 網路請求結束
return cached || fetchPromise; // 有快取用快取,沒有就等網路 // 離線容錯
}) // 快取查詢結束
); // respondWith 結束
}); // fetch 事件結束
❌ 錯誤 3:觸控按鈕太小,店員按不到
/* ❌ 錯誤:按鈕太小 */ /* 手指按不準 */
.product-btn {
width: 30px; /* 太小了! */ /* 手指至少需要 44px */
height: 30px; /* 太小了! */ /* 觸控螢幕操作困難 */
font-size: 10px; /* 字太小看不清 */ /* 店員要瞇眼睛 */
}
/* ✅ 正確:按鈕至少 44x44px */ /* Apple HIG 觸控最小尺寸建議 */
.product-btn {
min-width: 80px; /* 足夠的寬度 */ /* 手指輕鬆點擊 */
min-height: 60px; /* 足夠的高度 */ /* 不會按錯 */
font-size: 16px; /* 清楚的字體 */ /* 店員一目了然 */
padding: 12px; /* 充足的內距 */ /* 點擊區域更大 */
}