架構概覽

┌─────────────────────────────────────────────────────────────────┐
│                        用戶瀏覽流程                              │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Webflow (前端)                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐        │
│  │ 首頁     │  │ 商品列表  │  │ 商品詳情  │  │ 購物車   │        │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘        │
│                         │                                       │
│              JavaScript Integration Layer                       │
└─────────────────────────┬───────────────────────────────────────┘
                          │ GraphQL API
                          ▼
┌─────────────────────────────────────────────────────────────────┐
│                  Shopify Storefront API                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐        │
│  │ 商品資料  │  │ 庫存管理  │  │ 購物車   │  │ 結帳     │        │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘        │
└─────────────────────────────────────────────────────────────────┘

第一步: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)