架構概覽
┌─────────────────────────────────────────────────────────────────┐
│ 用戶瀏覽流程 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Webflow (前端) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 首頁 │ │ 商品列表 │ │ 商品詳情 │ │ 購物車 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ JavaScript Integration Layer │
└─────────────────────────┬───────────────────────────────────────┘
│ GraphQL API
▼
┌─────────────────────────────────────────────────────────────────┐
│ Shopify Storefront API │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 商品資料 │ │ 庫存管理 │ │ 購物車 │ │ 結帳 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
- 使用 Claude Code 將 Webflow 靜態 HTML 轉換為 Shopify Theme 完整指南 (1)
- Webflow 持續管理設計 + Shopify Theme 同步方案 (2)
- Headless 架構完整實作指南:Webflow + Shopify Storefront API (3)
第一步:Shopify 設定
1.1 建立 Storefront API 存取權限
在 Shopify 後台操作:
Settings → Apps and sales channels → Develop apps → Create an app
設定 Storefront API 權限(勾選以下項目):
| 權限 | 用途 |
|---|---|
unauthenticated_read_product_listings |
讀取商品資料 |
unauthenticated_read_product_inventory |
讀取庫存狀態 |
unauthenticated_read_product_tags |
讀取商品標籤 |
unauthenticated_read_collection_listings |
讀取商品系列 |
unauthenticated_write_checkouts |
建立結帳 |
unauthenticated_read_checkouts |
讀取結帳狀態 |
unauthenticated_read_customer_tags |
讀取顧客標籤 |
取得 Storefront Access Token:
安裝 App 後,複製 Storefront API access token(以 shpat_ 開頭)
第二步:核心整合程式碼
2.1 完整的 Shopify-Webflow Bridge
請在 Claude Code 中執行以下指令來生成完整程式碼:
claude
然後輸入:
請幫我建立一個完整的 Webflow + Shopify Storefront API 整合方案。
專案需求:
1. 使用 Shopify Storefront API 2024-01 版本
2. 純 JavaScript(ES6+),不使用框架
3. 支援繁體中文介面
功能清單:
- 商品列表載入(支援分頁、篩選、排序)
- 商品詳情頁(變體選擇、圖片輪播)
- 購物車(新增、更新數量、刪除、側邊欄顯示)
- 結帳流程(導向 Shopify Checkout)
- 庫存即時檢查
- 商品搜尋
- 收藏商品(localStorage)
Webflow 整合方式:
- 使用 data-* 屬性標記元素
- 自動偵測並綁定事件
- 支援 Webflow 的動畫和互動
請提供:
1. 主要 JavaScript 檔案(模組化結構)
2. Webflow 設定說明文件
3. 所需的 Custom Attributes 清單
4. CSS 樣式建議
檔案請存放在 /home/claude/webflow-shopify-headless/ 目錄
2.2 核心程式碼結構
Claude Code 會生成類似以下的檔案結構:
webflow-shopify-headless/
├── src/
│ ├── config.js # 設定檔
│ ├── shopify-client.js # API 客戶端
│ ├── modules/
│ │ ├── products.js # 商品模組
│ │ ├── cart.js # 購物車模組
│ │ ├── checkout.js # 結帳模組
│ │ └── search.js # 搜尋模組
│ ├── utils/
│ │ ├── dom.js # DOM 操作工具
│ │ ├── storage.js # 本地儲存
│ │ └── formatter.js # 格式化工具
│ └── index.js # 主入口
├── dist/
│ └── shopify-webflow.min.js # 打包後的檔案
├── docs/
│ └── webflow-setup.md # Webflow 設定說明
└── package.json
2.3 關鍵程式碼範例
config.js - 設定檔
// config.js
export const CONFIG = {
shopify: {
domain: 'your-store.myshopify.com',
storefrontAccessToken: 'your-storefront-access-token',
apiVersion: '2024-01'
},
currency: {
code: 'TWD',
symbol: 'NT$',
locale: 'zh-TW'
},
cart: {
storageKey: 'shopify_cart_id',
drawerEnabled: true
}
};
shopify-client.js - API 客戶端
// shopify-client.js
import { CONFIG } from './config.js';
class ShopifyClient {
constructor() {
this.endpoint = `https://${CONFIG.shopify.domain}/api/${CONFIG.shopify.apiVersion}/graphql.json`;
this.headers = {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': CONFIG.shopify.storefrontAccessToken
};
}
async query(graphqlQuery, variables = {}) {
try {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: this.headers,
body: JSON.stringify({ query: graphqlQuery, variables })
});
const data = await response.json();
if (data.errors) {
console.error('Shopify API Error:', data.errors);
throw new Error(data.errors[0].message);
}
return data.data;
} catch (error) {
console.error('Request failed:', error);
throw error;
}
}
}
export const shopifyClient = new ShopifyClient();
modules/products.js - 商品模組
// modules/products.js
import { shopifyClient } from '../shopify-client.js';
import { formatPrice, createProductHTML } from '../utils/formatter.js';
const PRODUCTS_QUERY = `
query getProducts($first: Int!, $after: String, $query: String) {
products(first: $first, after: $after, query: $query) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
handle
description
descriptionHtml
productType
tags
vendor
priceRange {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
compareAtPriceRange {
minVariantPrice {
amount
}
}
images(first: 10) {
edges {
node {
id
url
altText
width
height
}
}
}
variants(first: 100) {
edges {
node {
id
title
price {
amount
currencyCode
}
compareAtPrice {
amount
}
availableForSale
quantityAvailable
selectedOptions {
name
value
}
image {
url
altText
}
}
}
}
options {
id
name
values
}
}
}
}
}
`;
const PRODUCT_BY_HANDLE_QUERY = `
query getProductByHandle($handle: String!) {
product(handle: $handle) {
id
title
handle
description
descriptionHtml
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 10) {
edges {
node {
id
url
altText
}
}
}
variants(first: 100) {
edges {
node {
id
title
price {
amount
currencyCode
}
availableForSale
quantityAvailable
selectedOptions {
name
value
}
}
}
}
options {
id
name
values
}
}
}
`;
export class ProductsModule {
constructor() {
this.products = [];
this.pageInfo = null;
}
async fetchProducts(options = {}) {
const { first = 12, after = null, query = null } = options;
const data = await shopifyClient.query(PRODUCTS_QUERY, {
first,
after,
query
});
this.products = data.products.edges.map(edge => edge.node);
this.pageInfo = data.products.pageInfo;
return {
products: this.products,
pageInfo: this.pageInfo
};
}
async fetchProductByHandle(handle) {
const data = await shopifyClient.query(PRODUCT_BY_HANDLE_QUERY, { handle });
return data.product;
}
async fetchCollection(handle, first = 20) {
const COLLECTION_QUERY = `
query getCollection($handle: String!, $first: Int!) {
collection(handle: $handle) {
id
title
description
products(first: $first) {
edges {
node {
id
title
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
url
altText
}
}
}
variants(first: 1) {
edges {
node {
id
availableForSale
}
}
}
}
}
}
}
}
`;
const data = await shopifyClient.query(COLLECTION_QUERY, { handle, first });
return data.collection;
}
renderProductList(containerSelector, products) {
const container = document.querySelector(containerSelector);
if (!container) return;
container.innerHTML = products.map(product => this.createProductCard(product)).join('');
this.bindProductEvents(container);
}
createProductCard(product) {
const image = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice;
const comparePrice = product.compareAtPriceRange?.minVariantPrice;
const firstVariant = product.variants.edges[0]?.node;
const hasDiscount = comparePrice && parseFloat(comparePrice.amount) > parseFloat(price.amount);
const isAvailable = firstVariant?.availableForSale;
return `
<div class="product-card" data-product-handle="${product.handle}">
<a href="/products/${product.handle}" class="product-card-link">
<div class="product-image-wrapper">
${image ? `<img src="${image.url}" alt="${image.altText || product.title}" loading="lazy" />` : ''}
${!isAvailable ? '<span class="sold-out-badge">售罄</span>' : ''}
${hasDiscount ? '<span class="sale-badge">特價</span>' : ''}
</div>
<div class="product-info">
<h3 class="product-title">${product.title}</h3>
<div class="product-price">
${hasDiscount ? `<span class="compare-price">${formatPrice(comparePrice.amount)}</span>` : ''}
<span class="current-price">${formatPrice(price.amount)}</span>
</div>
</div>
</a>
<button
class="quick-add-btn"
data-variant-id="${firstVariant?.id}"
${!isAvailable ? 'disabled' : ''}
>
${isAvailable ? '加入購物車' : '售罄'}
</button>
</div>
`;
}
bindProductEvents(container) {
container.querySelectorAll('.quick-add-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const variantId = btn.dataset.variantId;
if (!variantId) return;
btn.disabled = true;
btn.textContent = '加入中...';
try {
await window.ShopifyWebflow.cart.addItem(variantId, 1);
btn.textContent = '已加入!';
setTimeout(() => {
btn.textContent = '加入購物車';
btn.disabled = false;
}, 1500);
} catch (error) {
btn.textContent = '錯誤,請重試';
btn.disabled = false;
}
});
});
}
}
modules/cart.js - 購物車模組
// modules/cart.js
import { shopifyClient } from '../shopify-client.js';
import { CONFIG } from '../config.js';
import { formatPrice } from '../utils/formatter.js';
const CREATE_CART_MUTATION = `
mutation createCart($lines: [CartLineInput!]) {
cartCreate(input: { lines: $lines }) {
cart {
id
checkoutUrl
totalQuantity
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
}
}
merchandise {
... on ProductVariant {
id
title
price {
amount
}
image {
url
altText
}
product {
title
handle
}
selectedOptions {
name
value
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const GET_CART_QUERY = `
query getCart($cartId: ID!) {
cart(id: $cartId) {
id
checkoutUrl
totalQuantity
cost {
totalAmount {
amount
currencyCode
}
subtotalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
}
}
merchandise {
... on ProductVariant {
id
title
price {
amount
}
image {
url
altText
}
product {
title
handle
}
selectedOptions {
name
value
}
}
}
}
}
}
}
}
`;
const ADD_TO_CART_MUTATION = `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
checkoutUrl
totalQuantity
cost {
totalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
}
}
merchandise {
... on ProductVariant {
id
title
price {
amount
}
image {
url
altText
}
product {
title
handle
}
selectedOptions {
name
value
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const UPDATE_CART_MUTATION = `
mutation updateCart($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
id
totalQuantity
cost {
totalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
product {
title
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
`;
const REMOVE_FROM_CART_MUTATION = `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
id
totalQuantity
cost {
totalAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
}
}
}
}
userErrors {
field
message
}
}
}
`;
export class CartModule {
constructor() {
this.cart = null;
this.cartId = localStorage.getItem(CONFIG.cart.storageKey);
this.listeners = [];
}
async init() {
if (this.cartId) {
try {
await this.fetchCart();
} catch (error) {
// Cart expired or invalid, create new one
localStorage.removeItem(CONFIG.cart.storageKey);
this.cartId = null;
}
}
this.updateUI();
}
async fetchCart() {
if (!this.cartId) return null;
const data = await shopifyClient.query(GET_CART_QUERY, {
cartId: this.cartId
});
this.cart = data.cart;
return this.cart;
}
async createCart(lines = []) {
const data = await shopifyClient.query(CREATE_CART_MUTATION, { lines });
if (data.cartCreate.userErrors.length > 0) {
throw new Error(data.cartCreate.userErrors[0].message);
}
this.cart = data.cartCreate.cart;
this.cartId = this.cart.id;
localStorage.setItem(CONFIG.cart.storageKey, this.cartId);
return this.cart;
}
async addItem(variantId, quantity = 1) {
const lines = [{ merchandiseId: variantId, quantity }];
if (!this.cartId) {
await this.createCart(lines);
} else {
const data = await shopifyClient.query(ADD_TO_CART_MUTATION, {
cartId: this.cartId,
lines
});
if (data.cartLinesAdd.userErrors.length > 0) {
throw new Error(data.cartLinesAdd.userErrors[0].message);
}
this.cart = data.cartLinesAdd.cart;
}
this.updateUI();
this.notifyListeners('item_added');
if (CONFIG.cart.drawerEnabled) {
this.openDrawer();
}
return this.cart;
}
async updateItemQuantity(lineId, quantity) {
if (!this.cartId) return;
const data = await shopifyClient.query(UPDATE_CART_MUTATION, {
cartId: this.cartId,
lines: [{ id: lineId, quantity }]
});
if (data.cartLinesUpdate.userErrors.length > 0) {
throw new Error(data.cartLinesUpdate.userErrors[0].message);
}
this.cart = data.cartLinesUpdate.cart;
this.updateUI();
this.notifyListeners('item_updated');
return this.cart;
}
async removeItem(lineId) {
if (!this.cartId) return;
const data = await shopifyClient.query(REMOVE_FROM_CART_MUTATION, {
cartId: this.cartId,
lineIds: [lineId]
});
if (data.cartLinesRemove.userErrors.length > 0) {
throw new Error(data.cartLinesRemove.userErrors[0].message);
}
this.cart = data.cartLinesRemove.cart;
this.updateUI();
this.notifyListeners('item_removed');
return this.cart;
}
getCheckoutUrl() {
return this.cart?.checkoutUrl;
}
getTotalQuantity() {
return this.cart?.totalQuantity || 0;
}
getTotalPrice() {
return this.cart?.cost?.totalAmount?.amount || '0';
}
getItems() {
return this.cart?.lines?.edges?.map(edge => edge.node) || [];
}
// UI Methods
updateUI() {
this.updateCartCount();
this.updateCartDrawer();
}
updateCartCount() {
const countElements = document.querySelectorAll('[data-cart-count]');
const count = this.getTotalQuantity();
countElements.forEach(el => {
el.textContent = count;
el.style.display = count > 0 ? 'flex' : 'none';
});
}
updateCartDrawer() {
const drawer = document.querySelector('[data-cart-drawer]');
if (!drawer) return;
const itemsContainer = drawer.querySelector('[data-cart-items]');
const totalElement = drawer.querySelector('[data-cart-total]');
const emptyMessage = drawer.querySelector('[data-cart-empty]');
const checkoutBtn = drawer.querySelector('[data-cart-checkout]');
const items = this.getItems();
if (items.length === 0) {
if (itemsContainer) itemsContainer.innerHTML = '';
if (emptyMessage) emptyMessage.style.display = 'block';
if (checkoutBtn) checkoutBtn.style.display = 'none';
if (totalElement) totalElement.textContent = formatPrice('0');
return;
}
if (emptyMessage) emptyMessage.style.display = 'none';
if (checkoutBtn) checkoutBtn.style.display = 'block';
if (itemsContainer) {
itemsContainer.innerHTML = items.map(item => this.renderCartItem(item)).join('');
this.bindCartItemEvents(itemsContainer);
}
if (totalElement) {
totalElement.textContent = formatPrice(this.getTotalPrice());
}
}
renderCartItem(item) {
const { merchandise, quantity, id } = item;
const image = merchandise.image;
const options = merchandise.selectedOptions
.filter(opt => opt.value !== 'Default Title')
.map(opt => `${opt.name}: ${opt.value}`)
.join(' / ');
return `
<div class="cart-item" data-line-id="${id}">
<div class="cart-item-image">
${image ? `<img src="${image.url}" alt="${image.altText || merchandise.product.title}" />` : ''}
</div>
<div class="cart-item-details">
<a href="/products/${merchandise.product.handle}" class="cart-item-title">
${merchandise.product.title}
</a>
${options ? `<p class="cart-item-options">${options}</p>` : ''}
<p class="cart-item-price">${formatPrice(merchandise.price.amount)}</p>
</div>
<div class="cart-item-quantity">
<button class="qty-btn minus" data-action="decrease">−</button>
<input type="number" value="${quantity}" min="1" max="99" class="qty-input" />
<button class="qty-btn plus" data-action="increase">+</button>
</div>
<button class="cart-item-remove" data-action="remove">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
}
bindCartItemEvents(container) {
container.querySelectorAll('.cart-item').forEach(item => {
const lineId = item.dataset.lineId;
const input = item.querySelector('.qty-input');
item.querySelector('[data-action="decrease"]')?.addEventListener('click', () => {
const newQty = Math.max(1, parseInt(input.value) - 1);
this.updateItemQuantity(lineId, newQty);
});
item.querySelector('[data-action="increase"]')?.addEventListener('click', () => {
const newQty = Math.min(99, parseInt(input.value) + 1);
this.updateItemQuantity(lineId, newQty);
});
item.querySelector('[data-action="remove"]')?.addEventListener('click', () => {
this.removeItem(lineId);
});
input?.addEventListener('change', (e) => {
const newQty = Math.max(1, Math.min(99, parseInt(e.target.value) || 1));
this.updateItemQuantity(lineId, newQty);
});
});
}
openDrawer() {
const drawer = document.querySelector('[data-cart-drawer]');
const overlay = document.querySelector('[data-cart-overlay]');
if (drawer) {
drawer.classList.add('is-open');
document.body.classList.add('cart-drawer-open');
}
if (overlay) {
overlay.classList.add('is-visible');
}
}
closeDrawer() {
const drawer = document.querySelector('[data-cart-drawer]');
const overlay = document.querySelector('[data-cart-overlay]');
if (drawer) {
drawer.classList.remove('is-open');
document.body.classList.remove('cart-drawer-open');
}
if (overlay) {
overlay.classList.remove('is-visible');
}
}
// Event System
onChange(callback) {
this.listeners.push(callback);
}
notifyListeners(event) {
this.listeners.forEach(callback => callback(event, this.cart));
}
}
index.js - 主入口
// index.js
import { CONFIG } from './config.js';
import { ProductsModule } from './modules/products.js';
import { CartModule } from './modules/cart.js';
import { formatPrice } from './utils/formatter.js';
class ShopifyWebflow {
constructor() {
this.products = new ProductsModule();
this.cart = new CartModule();
this.initialized = false;
}
async init(customConfig = {}) {
// Merge custom config
Object.assign(CONFIG, customConfig);
// Initialize cart
await this.cart.init();
// Auto-bind elements
this.bindGlobalEvents();
this.initProductListings();
this.initProductPage();
this.initialized = true;
console.log('ShopifyWebflow initialized');
// Dispatch ready event
window.dispatchEvent(new CustomEvent('shopify-webflow:ready'));
}
bindGlobalEvents() {
// Cart toggle buttons
document.querySelectorAll('[data-cart-toggle]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
this.cart.openDrawer();
});
});
// Cart close buttons
document.querySelectorAll('[data-cart-close]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
this.cart.closeDrawer();
});
});
// Cart overlay click to close
document.querySelectorAll('[data-cart-overlay]').forEach(overlay => {
overlay.addEventListener('click', () => {
this.cart.closeDrawer();
});
});
// Checkout button
document.querySelectorAll('[data-cart-checkout]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const checkoutUrl = this.cart.getCheckoutUrl();
if (checkoutUrl) {
window.location.href = checkoutUrl;
}
});
});
// Escape key to close cart
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.cart.closeDrawer();
}
});
}
async initProductListings() {
const productLists = document.querySelectorAll('[data-product-list]');
for (const container of productLists) {
const collectionHandle = container.dataset.collection;
const limit = parseInt(container.dataset.limit) || 12;
try {
let products;
if (collectionHandle) {
const collection = await this.products.fetchCollection(collectionHandle, limit);
products = collection?.products?.edges?.map(e => e.node) || [];
} else {
const result = await this.products.fetchProducts({ first: limit });
products = result.products;
}
this.products.renderProductList(container, products);
} catch (error) {
console.error('Failed to load products:', error);
container.innerHTML = '<p class="error">無法載入商品,請稍後再試。</p>';
}
}
}
async initProductPage() {
const productContainer = document.querySelector('[data-product-page]');
if (!productContainer) return;
const handle = productContainer.dataset.productHandle || this.getProductHandleFromUrl();
if (!handle) return;
try {
const product = await this.products.fetchProductByHandle(handle);
if (product) {
this.renderProductPage(productContainer, product);
}
} catch (error) {
console.error('Failed to load product:', error);
}
}
getProductHandleFromUrl() {
const match = window.location.pathname.match(/\/products\/([^\/]+)/);
return match ? match[1] : null;
}
renderProductPage(container, product) {
// Update product title
const titleEl = container.querySelector('[data-product-title]');
if (titleEl) titleEl.textContent = product.title;
// Update product price
const priceEl = container.querySelector('[data-product-price]');
if (priceEl) {
priceEl.textContent = formatPrice(product.priceRange.minVariantPrice.amount);
}
// Update product description
const descEl = container.querySelector('[data-product-description]');
if (descEl) descEl.innerHTML = product.descriptionHtml;
// Initialize variant selector
this.initVariantSelector(container, product);
// Initialize image gallery
this.initImageGallery(container, product);
// Initialize add to cart
this.initAddToCart(container, product);
}
initVariantSelector(container, product) {
const optionsContainer = container.querySelector('[data-product-options]');
if (!optionsContainer || product.options.length === 0) return;
const variants = product.variants.edges.map(e => e.node);
let selectedOptions = {};
// Initialize with first variant's options
const firstVariant = variants[0];
firstVariant.selectedOptions.forEach(opt => {
selectedOptions[opt.name] = opt.value;
});
// Render options
optionsContainer.innerHTML = product.options
.filter(option => !(option.values.length === 1 && option.values[0] === 'Default Title'))
.map(option => `
<div class="product-option" data-option-name="${option.name}">
<label class="option-label">${option.name}</label>
<div class="option-values">
${option.values.map((value, index) => `
<button
class="option-value ${selectedOptions[option.name] === value ? 'is-selected' : ''}"
data-value="${value}"
>
${value}
</button>
`).join('')}
</div>
</div>
`).join('');
// Bind option events
optionsContainer.querySelectorAll('.option-value').forEach(btn => {
btn.addEventListener('click', () => {
const optionName = btn.closest('[data-option-name]').dataset.optionName;
const value = btn.dataset.value;
// Update selected options
selectedOptions[optionName] = value;
// Update UI
btn.closest('.option-values').querySelectorAll('.option-value').forEach(b => {
b.classList.remove('is-selected');
});
btn.classList.add('is-selected');
// Find matching variant
const matchingVariant = variants.find(v =>
v.selectedOptions.every(opt => selectedOptions[opt.name] === opt.value)
);
if (matchingVariant) {
this.updateSelectedVariant(container, matchingVariant, product);
}
});
});
// Store variants for later use
container._variants = variants;
container._selectedVariantId = firstVariant.id;
}
updateSelectedVariant(container, variant, product) {
container._selectedVariantId = variant.id;
// Update price
const priceEl = container.querySelector('[data-product-price]');
if (priceEl) {
priceEl.textContent = formatPrice(variant.price.amount);
}
// Update availability
const addBtn = container.querySelector('[data-add-to-cart]');
if (addBtn) {
if (variant.availableForSale) {
addBtn.disabled = false;
addBtn.textContent = '加入購物車';
} else {
addBtn.disabled = true;
addBtn.textContent = '售罄';
}
}
// Update image if variant has one
if (variant.image) {
const mainImage = container.querySelector('[data-product-main-image]');
if (mainImage) {
mainImage.src = variant.image.url;
mainImage.alt = variant.image.altText || product.title;
}
}
}
initImageGallery(container, product) {
const images = product.images.edges.map(e => e.node);
if (images.length === 0) return;
const mainImage = container.querySelector('[data-product-main-image]');
const thumbnails = container.querySelector('[data-product-thumbnails]');
if (mainImage && images[0]) {
mainImage.src = images[0].url;
mainImage.alt = images[0].altText || product.title;
}
if (thumbnails && images.length > 1) {
thumbnails.innerHTML = images.map((img, index) => `
<button class="thumbnail ${index === 0 ? 'is-active' : ''}" data-index="${index}">
<img src="${img.url}" alt="${img.altText || ''}" loading="lazy" />
</button>
`).join('');
thumbnails.querySelectorAll('.thumbnail').forEach(thumb => {
thumb.addEventListener('click', () => {
const index = parseInt(thumb.dataset.index);
const image = images[index];
if (mainImage) {
mainImage.src = image.url;
mainImage.alt = image.altText || product.title;
}
thumbnails.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('is-active'));
thumb.classList.add('is-active');
});
});
}
}
initAddToCart(container, product) {
const addBtn = container.querySelector('[data-add-to-cart]');
const qtyInput = container.querySelector('[data-quantity-input]');
if (!addBtn) return;
addBtn.addEventListener('click', async () => {
const variantId = container._selectedVariantId;
const quantity = qtyInput ? parseInt(qtyInput.value) || 1 : 1;
if (!variantId) return;
addBtn.disabled = true;
const originalText = addBtn.textContent;
addBtn.textContent = '加入中...';
try {
await this.cart.addItem(variantId, quantity);
addBtn.textContent = '已加入!';
setTimeout(() => {
addBtn.textContent = originalText;
addBtn.disabled = false;
}, 1500);
} catch (error) {
console.error('Add to cart failed:', error);
addBtn.textContent = '錯誤,請重試';
setTimeout(() => {
addBtn.textContent = originalText;
addBtn.disabled = false;
}, 2000);
}
});
}
}
// Initialize and expose globally
window.ShopifyWebflow = new ShopifyWebflow();
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Check for auto-init config in script tag
const script = document.querySelector('script[data-shopify-domain]');
if (script) {
window.ShopifyWebflow.init({
shopify: {
domain: script.dataset.shopifyDomain,
storefrontAccessToken: script.dataset.storefrontToken
}
});
}
});
} else {
const script = document.querySelector('script[data-shopify-domain]');
if (script) {
window.ShopifyWebflow.init({
shopify: {
domain: script.dataset.shopifyDomain,
storefrontAccessToken: script.dataset.storefrontToken
}
});
}
}
export default window.ShopifyWebflow;
utils/formatter.js - 格式化工具
// utils/formatter.js
import { CONFIG } from '../config.js';
export function formatPrice(amount, currencyCode = CONFIG.currency.code) {
const numAmount = parseFloat(amount);
return new Intl.NumberFormat(CONFIG.currency.locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(numAmount);
}
export function formatDate(dateString) {
return new Intl.DateTimeFormat(CONFIG.currency.locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(dateString));
}
第三步:Webflow 設定
3.1 Custom Attributes 對照表
在 Webflow Designer 中,為對應元素添加以下 Custom Attributes:
| 元素 | Attribute | 值 | 說明 |
|---|---|---|---|
| 商品列表容器 | data-product-list |
(空) | 商品列表容器 |
data-collection |
collection-handle | 指定商品系列 | |
data-limit |
12 | 顯示數量 | |
| 商品頁容器 | data-product-page |
(空) | 商品詳情頁容器 |
data-product-handle |
product-handle | 商品 handle | |
| 商品標題 | data-product-title |
(空) | 自動填入標題 |
| 商品價格 | data-product-price |
(空) | 自動填入價格 |
| 商品描述 | data-product-description |
(空) | 自動填入描述 |
| 商品選項容器 | data-product-options |
(空) | 變體選項容器 |
| 主圖 | data-product-main-image |
(空) | 商品主圖片 |
| 縮圖容器 | data-product-thumbnails |
(空) | 縮圖列表 |
| 加入購物車按鈕 | data-add-to-cart |
(空) | 加入購物車 |
| 數量輸入 | data-quantity-input |
(空) | 商品數量 |
| 購物車圖示 | data-cart-toggle |
(空) | 開啟購物車 |
| 購物車數量 | data-cart-count |
(空) | 顯示購物車數量 |
| 購物車側邊欄 | data-cart-drawer |
(空) | 購物車抽屜 |
| 購物車關閉 | data-cart-close |
(空) | 關閉購物車 |
| 購物車遮罩 | data-cart-overlay |
(空) | 背景遮罩 |
| 購物車商品容器 | data-cart-items |
(空) | 購物車商品列表 |
| 購物車總計 | data-cart-total |
(空) | 總金額 |
| 購物車空狀態 | data-cart-empty |
(空) | 空購物車訊息 |
| 結帳按鈕 | data-cart-checkout |
(空) | 前往結帳 |
3.2 Webflow 頁面結構範例
首頁商品列表:
<!-- 在 Webflow 中設計這個結構,並添加 data attributes -->
<section class="products-section">
<h2>精選商品</h2>
<div
class="products-grid"
data-product-list
data-collection="featured"
data-limit="8"
>
<!-- 商品會自動填入這裡 -->
</div>
</section>
商品詳情頁:
<div class="product-page" data-product-page>
<div class="product-gallery">
<img data-product-main-image src="" alt="" class="main-image" />
<div data-product-thumbnails class="thumbnails"></div>
</div>
<div class="product-info">
<h1 data-product-title></h1>
<p data-product-price class="price"></p>
<div data-product-description class="description"></div>
<div data-product-options class="options"></div>
<div class="add-to-cart-section">
<input type="number" value="1" min="1" data-quantity-input class="qty-input" />
<button data-add-to-cart class="add-btn">加入購物車</button>
</div>
</div>
</div>
購物車側邊欄:
<!-- 放在所有頁面的共用元件中 -->
<div data-cart-overlay class="cart-overlay"></div>
<aside data-cart-drawer class="cart-drawer">
<div class="cart-header">
<h3>購物車</h3>
<button data-cart-close class="close-btn">×</button>
</div>
<div data-cart-empty class="cart-empty">
<p>您的購物車是空的</p>
</div>
<div data-cart-items class="cart-items"></div>
<div class="cart-footer">
<div class="cart-total-row">
<span>小計</span>
<span data-cart-total>NT$0</span>
</div>
<button data-cart-checkout class="checkout-btn">前往結帳</button>
</div>
</aside>
3.3 載入腳本
在 Webflow Project Settings → Custom Code → Footer Code 中加入:
<!-- Shopify Webflow Integration -->
<script
src="https://your-cdn.com/shopify-webflow.min.js"
data-shopify-domain="your-store.myshopify.com"
data-storefront-token="your-storefront-access-token"
></script>
或者手動初始化:
<script src="https://your-cdn.com/shopify-webflow.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
window.ShopifyWebflow.init({
shopify: {
domain: 'your-store.myshopify.com',
storefrontAccessToken: 'your-token-here'
},
currency: {
code: 'TWD',
symbol: 'NT$',
locale: 'zh-TW'
}
});
});
</script>
第四步:必要的 CSS 樣式
/* 購物車側邊欄樣式 */
.cart-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
z-index: 999;
}
.cart-overlay.is-visible {
opacity: 1;
visibility: visible;
}
.cart-drawer {
position: fixed;
top: 0;
right: 0;
width: 100%;
max-width: 420px;
height: 100vh;
background: white;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 1000;
display: flex;
flex-direction: column;
}
.cart-drawer.is-open {
transform: translateX(0);
}
body.cart-drawer-open {
overflow: hidden;
}
/* 購物車商品項目 */
.cart-item {
display: flex;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.cart-item-image {
width: 80px;
flex-shrink: 0;
}
.cart-item-image img {
width: 100%;
height: auto;
object-fit: cover;
}
.cart-item-details {
flex: 1;
}
.cart-item-quantity {
display: flex;
align-items: center;
gap: 0.5rem;
}
.qty-btn {
width: 28px;
height: 28px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
}
.qty-input {
width: 40px;
text-align: center;
border: 1px solid #ddd;
padding: 4px;
}
/* 商品卡片 */
.product-card {
position: relative;
}
.sold-out-badge,
.sale-badge {
position: absolute;
top: 10px;
left: 10px;
padding: 4px 8px;
font-size: 12px;
font-weight: 600;
}
.sold-out-badge {
background: #666;
color: white;
}
.sale-badge {
background: #e74c3c;
color: white;
}
/* 變體選項 */
.product-option {
margin-bottom: 1rem;
}
.option-values {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.option-value {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.option-value.is-selected {
border-color: #000;
background: #000;
color: white;
}
/* 縮圖 */
.thumbnails {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.thumbnail {
width: 60px;
height: 60px;
padding: 0;
border: 2px solid transparent;
cursor: pointer;
}
.thumbnail.is-active {
border-color: #000;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
第五步:部署流程
5.1 打包腳本
使用 Claude Code 建立打包配置:
請幫我建立一個 Rollup 或 esbuild 配置,將 webflow-shopify-headless 專案打包為單一 JS 檔案:
需求:
1. 輸出 ES Module 和 UMD 格式
2. 壓縮並產生 sourcemap
3. 支援舊版瀏覽器(ES5 轉譯)
4. 輸出檔案名稱包含版本號
5.2 託管選項
| 平台 | 優點 | 適合 |
|---|---|---|
| Cloudflare Pages | 免費、快速、全球 CDN | 推薦 |
| jsDelivr + GitHub | 免費、自動 CDN | 開源專案 |
| Vercel | 簡單部署 | 已有 Vercel 帳號 |
| 自建 S3 + CloudFront | 完全控制 | 企業級 |
5.3 版本更新流程
1. 修改程式碼
2. 更新版本號 (package.json)
3. npm run build
4. 部署到 CDN
5. (可選)通知客戶更新腳本版本
常見問題解答
Q: 結帳時會離開 Webflow 網站嗎?
A: 是的,結帳會導向 Shopify Checkout。這是最安全的做法,因為 Shopify Checkout 符合 PCI DSS 標準,處理敏感支付資訊。
Q: 可以自訂結帳頁面嗎?
A: Shopify Plus 方案可以完全自訂 Checkout。一般方案可在 Shopify 後台調整品牌設定(Logo、顏色、字型)。
Q: 如何處理多語言?
A: 使用 Shopify 的 Translate & Adapt app 管理商品翻譯,前端根據用戶語言切換 API 的 @inContext 參數。Q: SEO 會受影響嗎?
A: 商品資料透過 JavaScript 載入,對 SEO 有影響。建議:使用 Webflow CMS 建立靜態商品頁面作為 fallback或使用預渲染服務(Prerender.io)
