商用網站架設實務

LESSON-27 · starter

固定 Sheet schema、伺服器權威計價、去重與完整狀態

你會依序完成3個本堂專屬微步驟;每一步只修改一個責任,看到結果後才進下一步。 這頁用 可觀察證據anchor 指出下一個動作。

Starter3 個小成果
Checkpoint一個核心缺口
Solution4 個差異

開始前確認

這一頁的結果:入口頁可以開啟

工作方式:先把這一階段複製到自己的工作資料夾,開啟 files/index.html,記下修改前的畫面、DOM、Console 或 Network狀態。

會用到的檔案:assets/logo-mark.svgassets/product-band.svgassets/product-bench.svgassets/product-bike.svgassets/product-dumbbell.svgassets/product-kettlebell.svgassets/product-mat.svgcart.htmlcheckout.htmlcontact.htmlcss/style.cssindex.htmljs/api.jsjs/cart.jsjs/checkout.jsjs/common.jsjs/config.jsjs/contact.jsjs/home.jsjs/order-success.jsjs/products.jsjs/store.jsorder-success.htmlproducts.htmlrobots.txtsitemap.xmlsmoke-test.mdapps-script/Code.gsapps-script/SETUP.mdapps-script/appsscript.json

本堂焦點:固定 Sheet schema、伺服器權威計價、去重與完整狀態

照這個順序做

一次只做一張卡。完成後先重新整理入口,再留下證據,才進入下一張。

TODO-1

欄序是 API、計價、去重與人工查帳的共同契約,必須先固定再 append。

目的

欄序是 API、計價、去重與人工查帳的共同契約,必須先固定再 append。

修改檔案

apps-script/SETUP.md、apps-script/Code.gs

程式碼位置

Products Orders Contacts schema

現在要做:請在 apps-script/SETUP.mdapps-script/Code.gs 的「Products Orders Contacts schema」完成:固定 Products、Orders、Contacts 三張工作表 headers 與資料型別

貼上位置:apps-script/SETUP.mdapps-script/Code.gs:Products Orders Contacts schema 區塊。

Products: productId,name,image,price,salePrice,active
Orders: orderNo,submissionId,timestamp,buyer,gender,phone,address,email,subtotal,shipping,total,status,itemsJson
Contacts: contactNo,submissionId,timestamp,name,gender,phone,email,message,status

預期結果:Products 為 6 欄、Orders 為 13 欄、Contacts 為 9 欄,header 名稱、順序與 row builder 完全一致。

驗收證據:SETUP.md headers、Code.gs row builders 與測試 Sheet 第一列逐欄對照。

卡住時先看這裡

常見錯誤:只寫文件沒同步程式欄序,或把電話以 number 儲存而遺失前導零。

第一個檢查:先逐欄比較三張 header 與各 row builder;Orders 必須從 orderNo 到 itemsJson,Contacts 必須從 contactNo 到 status。

TODO-2

前端金額可被修改,訂單保存前必須由 GAS 重新查價與計算。

目的

前端金額可被修改,訂單保存前必須由 GAS 重新查價與計算。

修改檔案

apps-script/Code.gs

程式碼位置

authoritative order calculation

現在要做:請在 apps-script/Code.gs 的「authoritative order calculation」完成:依 Products Sheet 的 active 商品與有效售價重建 items snapshot,計算 subtotalshippingtotal 並產生 orderNo/status

貼上位置:apps-script/Code.gs:authoritative order calculation 區塊。

const subtotal = items.reduce((sum,item) => sum + item.unitPrice * item.qty, 0);
const shipping = subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_FEE;
const total = subtotal + shipping;

預期結果:竄改前端三個金額不影響保存結果;未知/停用商品被拒絕;total=subtotal+shipping

驗收證據:GAS 計算結果、Orders row、竄改 payload 對照與算式。

卡住時先看這裡

常見錯誤:直接保存 payload.total,或只重算總額卻保存前端 item price。

第一個檢查:先把前端 total 改成 1,再確認 Orders row 仍由 Products 價格算出。

TODO-3

網路結果不確定時不能重複訂單,也不能讓按鈕永久停用或清空輸入。

目的

網路結果不確定時不能重複訂單,也不能讓按鈕永久停用或清空輸入。

修改檔案

apps-script/Code.gs、js/api.js、js/checkout.js、js/contact.js

程式碼位置

lock dedupe and five states

現在要做:請在 apps-script/Code.gsjs/api.jsjs/checkout.jsjs/contact.js 的「lock dedupe and five states」完成:以 LockServicesubmissionId 去重與 append 包在同一臨界區,timeout 重試沿用同 id,UI 完成 idle/loading/success/error/timeout

貼上位置:apps-script/Code.gsjs/api.jsjs/checkout.jsjs/contact.js:lock dedupe and five states 區塊。

const lock = LockService.getScriptLock();
lock.waitLock(5000);
try { if (findBySubmissionId_(sheet, data.submissionId)) return duplicateResponse_(); appendOnce_(); } finally { lock.releaseLock(); }

預期結果:相同 order/contact submissionId 各只寫一列;五種狀態可重現;所有完成分支解除 disabled,timeout 後可安全重試。

驗收證據:兩次 request、Sheet row count、response deduplicated、data-state/aria-busy/disabled 與保留輸入。

卡住時先看這裡

常見錯誤:先 append 才查重、重試產生新 id,或只在 success 解除 disabled。

第一個檢查:先送出同一 submissionId 兩次並數 Sheet 列,再逐一觸發五種 UI 狀態。

階段檔案

完整檔案收在可展開的卡片中;先完成上方微步驟,再用這裡查閱與複製。檔案位置要和目前的相對路徑一致。

開啟 starter 的可執行入口

assets/logo-mark.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="logo-title">
  <title id="logo-title">鐵刻運動標誌</title>
  <rect width="64" height="64" rx="14" fill="#f6c90e"/>
  <path d="M11 25v14m7-19v24m28-24v24m7-19v14M18 32h28" fill="none" stroke="#20242a" stroke-width="6" stroke-linecap="round"/>
</svg>
assets/product-band.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480" role="img" aria-labelledby="title desc">
  <title id="title">五段阻力帶套組</title>
  <desc id="desc">交疊排列的環狀阻力帶</desc>
  <rect width="640" height="480" rx="32" fill="#303841"/>
  <path d="M0 390 640 260v220H0Z" fill="#f6c90e"/>
  <g fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="250" cy="260" rx="120" ry="62"/><ellipse cx="330" cy="245" rx="120" ry="62"/><ellipse cx="410" cy="230" rx="120" ry="62"/></g>
  <text x="40" y="62" fill="#f6c90e" font-family="system-ui,sans-serif" font-size="22" font-weight="800">IRONFIT EQUIPMENT</text>
  <text x="40" y="438" fill="#20242a" font-family="system-ui,sans-serif" font-size="30" font-weight="900">五段阻力帶套組</text>
</svg>
assets/product-bench.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480" role="img" aria-labelledby="title desc">
  <title id="title">多角度訓練椅</title>
  <desc id="desc">可調整椅背角度的訓練椅</desc>
  <rect width="640" height="480" rx="32" fill="#303841"/>
  <path d="M0 390 640 260v220H0Z" fill="#f6c90e"/>
  <g fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><path d="m160 285 245-55 70 35-245 55zM220 310l-35 85M425 260l45 120M405 230 330 105l-55 25 70 120"/></g>
  <text x="40" y="62" fill="#f6c90e" font-family="system-ui,sans-serif" font-size="22" font-weight="800">IRONFIT EQUIPMENT</text>
  <text x="40" y="438" fill="#20242a" font-family="system-ui,sans-serif" font-size="30" font-weight="900">多角度訓練椅</text>
</svg>
assets/product-bike.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480" role="img" aria-labelledby="title desc">
  <title id="title">靜音磁控健身車</title>
  <desc id="desc">附座椅與踏板的室內健身車</desc>
  <rect width="640" height="480" rx="32" fill="#303841"/>
  <path d="M0 390 640 260v220H0Z" fill="#f6c90e"/>
  <g fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><circle cx="235" cy="315" r="82"/><circle cx="420" cy="315" r="82"/><path d="m235 315 85-135 100 135H235l85-135 85 5M390 165h70M310 175l-55-45"/></g>
  <text x="40" y="62" fill="#f6c90e" font-family="system-ui,sans-serif" font-size="22" font-weight="800">IRONFIT EQUIPMENT</text>
  <text x="40" y="438" fill="#20242a" font-family="system-ui,sans-serif" font-size="30" font-weight="900">靜音磁控健身車</text>
</svg>
assets/product-dumbbell.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480" role="img" aria-labelledby="title desc">
  <title id="title">可調式六角啞鈴組</title>
  <desc id="desc">兩端六角配重的啞鈴</desc>
  <rect width="640" height="480" rx="32" fill="#303841"/>
  <path d="M0 390 640 260v220H0Z" fill="#f6c90e"/>
  <g fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><path d="M150 240h340M120 195v90M165 175v130M475 175v130M520 195v90"/></g>
  <text x="40" y="62" fill="#f6c90e" font-family="system-ui,sans-serif" font-size="22" font-weight="800">IRONFIT EQUIPMENT</text>
  <text x="40" y="438" fill="#20242a" font-family="system-ui,sans-serif" font-size="30" font-weight="900">可調式六角啞鈴組</text>
</svg>
assets/product-kettlebell.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480" role="img" aria-labelledby="title desc">
  <title id="title">鑄鐵競技壺鈴</title>
  <desc id="desc">具有大型握把的壺鈴</desc>
  <rect width="640" height="480" rx="32" fill="#303841"/>
  <path d="M0 390 640 260v220H0Z" fill="#f6c90e"/>
  <g fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><path d="M250 195c0-90 140-90 140 0"/><path d="M235 215c-55 110 10 170 85 170s140-60 85-170z"/></g>
  <text x="40" y="62" fill="#f6c90e" font-family="system-ui,sans-serif" font-size="22" font-weight="800">IRONFIT EQUIPMENT</text>
  <text x="40" y="438" fill="#20242a" font-family="system-ui,sans-serif" font-size="30" font-weight="900">鑄鐵競技壺鈴</text>
</svg>
assets/product-mat.svg本地商品或品牌資產:不依賴外部圖片服務。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480" role="img" aria-labelledby="title desc">
  <title id="title">止滑訓練瑜珈墊</title>
  <desc id="desc">捲起一角的運動墊</desc>
  <rect width="640" height="480" rx="32" fill="#303841"/>
  <path d="M0 390 640 260v220H0Z" fill="#f6c90e"/>
  <g fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"><path d="M160 290 365 170l120 75-205 120z"/><path d="M365 170c80-20 110 24 120 75"/></g>
  <text x="40" y="62" fill="#f6c90e" font-family="system-ui,sans-serif" font-size="22" font-weight="800">IRONFIT EQUIPMENT</text>
  <text x="40" y="438" fill="#20242a" font-family="system-ui,sans-serif" font-size="30" font-weight="900">止滑訓練瑜珈墊</text>
</svg>
cart.html鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="調整鐵刻運動購物車的商品數量並查看前端估算金額。">
  <meta property="og:type" content="website">
  <meta property="og:title" content="購物車|鐵刻運動">
  <meta property="og:description" content="調整鐵刻運動購物車的商品數量並查看前端估算金額。">
  <meta property="og:url" content="https://example.com/project/e-commerce/cart.html">
  <title>購物車|鐵刻運動</title>
  <link rel="canonical" href="https://example.com/project/e-commerce/cart.html">
  <link rel="icon" href="assets/logo-mark.svg" type="image/svg+xml">
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <a class="skip-link" href="#main-content">跳到主要內容</a>
  <header class="site-header">
    <div class="container header-inner">
      <a class="brand" href="index.html" aria-label="鐵刻運動首頁">
        <img src="assets/logo-mark.svg" width="44" height="44" alt="">
        <span>鐵刻運動</span>
      </a>
      <nav class="main-nav" aria-label="主要導覽">
        <a href="index.html">首頁</a>
        <a href="products.html">商品</a>
        <a href="cart.html" aria-current="page">購物車</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <a class="cart-shortcut" href="cart.html" aria-label="前往購物車">
        購物車 <span class="cart-count" data-cart-count aria-live="polite">0</span>
      </a>
    </div>
  </header>
  <div class="mode-banner" data-mode-banner role="status" aria-live="polite"></div>

  <main id="main-content" tabindex="-1">
    <section class="page-hero"><div class="container"><p class="eyebrow">Your cart</p><h1>購物車</h1><p>增加、減少或移除商品;畫面金額是送單前估算。</p></div></section>
    <section class="section">
      <div class="container cart-layout">
        <div>
          <p id="cart-status" class="status-box" role="status" aria-live="polite"></p>
          <div id="cart-loading" class="loading-block" role="status" aria-live="polite">
            <span class="loading-spinner" aria-hidden="true"></span>
            <span>正在連線 GAS,整理購物車商品…</span>
          </div>
          <div id="cart-empty" class="empty-state" hidden><h2>購物車目前是空的</h2><p>先挑一件適合你的訓練器材吧。</p><a class="button" href="products.html">前往選購</a></div>
          <div id="cart-list" class="cart-list" aria-busy="true"></div>
        </div>
        <aside class="panel" aria-labelledby="cart-summary-title">
          <h2 id="cart-summary-title">金額估算</h2>
          <div class="summary-list">
            <div class="summary-row"><span>商品總額</span><output id="cart-subtotal">NT$0</output></div>
            <div class="summary-row"><span>運費</span><output id="cart-shipping">NT$0</output></div>
            <div class="summary-row summary-row-total"><span>訂單總額</span><output id="cart-total">NT$0</output></div>
          </div>
          <p class="authority-note">這是前端估算。GAS 會依商品資料重新計算,結帳成功頁只顯示後端回應的總額與狀態。</p>
          <a id="checkout-link" class="button" href="checkout.html" hidden>填寫訂購資料</a>
        </aside>
      </div>
    </section>
  </main>
  <footer class="site-footer">
    <div class="container footer-inner">
      <div>
        <strong>鐵刻運動</strong>
        <p>把訓練帶進日常,一步一步建立自己的節奏。</p>
      </div>
      <nav aria-label="頁尾導覽">
        <a href="products.html">選購商品</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <p class="footer-note">教學示範網站,不提供付款、登入、庫存扣減或真實出貨。</p>
    </div>
  </footer>
  <script src="js/config.js" defer></script>
  <script src="js/api.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/common.js" defer></script>
  <script src="js/cart.js" defer></script>
</body>
</html>
checkout.html鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="填寫購買人聯絡與配送資料,建立鐵刻運動訂單。">
  <meta property="og:type" content="website">
  <meta property="og:title" content="填寫訂單|鐵刻運動">
  <meta property="og:description" content="填寫購買人聯絡與配送資料,建立鐵刻運動訂單。">
  <meta property="og:url" content="https://example.com/project/e-commerce/checkout.html">
  <title>填寫訂單|鐵刻運動</title>
  <link rel="canonical" href="https://example.com/project/e-commerce/checkout.html">
  <link rel="icon" href="assets/logo-mark.svg" type="image/svg+xml">
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <a class="skip-link" href="#main-content">跳到主要內容</a>
  <header class="site-header">
    <div class="container header-inner">
      <a class="brand" href="index.html" aria-label="鐵刻運動首頁">
        <img src="assets/logo-mark.svg" width="44" height="44" alt="">
        <span>鐵刻運動</span>
      </a>
      <nav class="main-nav" aria-label="主要導覽">
        <a href="index.html">首頁</a>
        <a href="products.html">商品</a>
        <a href="cart.html" aria-current="page">購物車</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <a class="cart-shortcut" href="cart.html" aria-label="前往購物車">
        購物車 <span class="cart-count" data-cart-count aria-live="polite">0</span>
      </a>
    </div>
  </header>
  <div class="mode-banner" data-mode-banner role="status" aria-live="polite"></div>

  <main id="main-content" tabindex="-1">
    <section class="page-hero"><div class="container"><p class="eyebrow">Checkout</p><h1>填寫訂單</h1><p>送出前請確認聯絡與配送資料。<span class="required">*</span> 為必填。</p></div></section>
    <section class="section">
      <div class="container checkout-layout">
        <div>
          <div id="checkout-loading" class="loading-block" role="status" aria-live="polite">
            <span class="loading-spinner" aria-hidden="true"></span>
            <span>正在連線 GAS,準備結帳資料…</span>
          </div>
          <div id="checkout-empty" class="empty-state" hidden><h2>沒有可結帳的商品</h2><a class="button" href="products.html">回商品頁</a></div>
          <form id="checkout-form" class="panel" aria-labelledby="buyer-title" aria-busy="true" hidden>
            <h2 id="buyer-title">購買人與配送資料</h2>
            <div class="form-grid">
              <div class="field"><label for="buyer-name">購買人 <span class="required" aria-hidden="true">*</span></label><input id="buyer-name" name="buyer" autocomplete="name" minlength="2" maxlength="80" required></div>
              <div class="field"><label for="buyer-gender">性別 <span class="required" aria-hidden="true">*</span></label><select id="buyer-gender" name="gender" required><option value="">請選擇</option><option value="女">女性</option><option value="男">男性</option><option value="其他">其他</option><option value="不透露">不透露</option></select></div>
              <div class="field"><label for="buyer-phone">電話 <span class="required" aria-hidden="true">*</span></label><input id="buyer-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="30" required></div>
              <div class="field"><label for="buyer-email">信箱 <span class="required" aria-hidden="true">*</span></label><input id="buyer-email" name="email" type="email" autocomplete="email" maxlength="254" required></div>
              <div class="field field-full"><label for="buyer-address">地址 <span class="required" aria-hidden="true">*</span></label><input id="buyer-address" name="address" autocomplete="street-address" minlength="6" maxlength="200" required></div>
            </div>
            <button id="checkout-submit" class="button" type="submit">建立訂單</button>
          </form>
          <p id="checkout-status" class="status-box" role="status" aria-live="assertive" tabindex="-1"></p>
        </div>
        <aside class="panel" aria-labelledby="checkout-summary-title">
          <h2 id="checkout-summary-title">送出前估算</h2>
          <ul id="checkout-items" class="order-items"></ul>
          <div class="summary-list">
            <div class="summary-row"><span>商品總額</span><output id="checkout-subtotal">NT$0</output></div>
            <div class="summary-row"><span>運費</span><output id="checkout-shipping">NT$0</output></div>
            <div class="summary-row summary-row-total"><span>訂單總額</span><output id="checkout-total">NT$0</output></div>
          </div>
          <p class="authority-note"><strong>伺服器具最終決定權:</strong>這裡只做使用者介面估算。建立訂單後,商品總額、運費、訂單總額及狀態全部以 GAS 回應覆蓋。</p>
        </aside>
      </div>
    </section>
  </main>
  <footer class="site-footer">
    <div class="container footer-inner">
      <div>
        <strong>鐵刻運動</strong>
        <p>把訓練帶進日常,一步一步建立自己的節奏。</p>
      </div>
      <nav aria-label="頁尾導覽">
        <a href="products.html">選購商品</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <p class="footer-note">教學示範網站,不提供付款、登入、庫存扣減或真實出貨。</p>
    </div>
  </footer>
  <script src="js/config.js" defer></script>
  <script src="js/api.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/common.js" defer></script>
  <script src="js/checkout.js" defer></script>
</body>
</html>
contact.html鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="以姓名、性別、電話、信箱與內容聯絡鐵刻運動。">
  <meta property="og:type" content="website">
  <meta property="og:title" content="聯絡我們|鐵刻運動">
  <meta property="og:description" content="以姓名、性別、電話、信箱與內容聯絡鐵刻運動。">
  <meta property="og:url" content="https://example.com/project/e-commerce/contact.html">
  <title>聯絡我們|鐵刻運動</title>
  <link rel="canonical" href="https://example.com/project/e-commerce/contact.html">
  <link rel="icon" href="assets/logo-mark.svg" type="image/svg+xml">
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <a class="skip-link" href="#main-content">跳到主要內容</a>
  <header class="site-header">
    <div class="container header-inner">
      <a class="brand" href="index.html" aria-label="鐵刻運動首頁">
        <img src="assets/logo-mark.svg" width="44" height="44" alt="">
        <span>鐵刻運動</span>
      </a>
      <nav class="main-nav" aria-label="主要導覽">
        <a href="index.html">首頁</a>
        <a href="products.html">商品</a>
        <a href="cart.html">購物車</a>
        <a href="contact.html" aria-current="page">聯絡我們</a>
      </nav>
      <a class="cart-shortcut" href="cart.html" aria-label="前往購物車">
        購物車 <span class="cart-count" data-cart-count aria-live="polite">0</span>
      </a>
    </div>
  </header>
  <div class="mode-banner" data-mode-banner role="status" aria-live="polite"></div>

  <main id="main-content" tabindex="-1">
    <section class="page-hero"><div class="container"><p class="eyebrow">Contact</p><h1>聯絡我們</h1><p>選購或器材使用有疑問嗎?留下訊息,我們會依你提供的方式回覆。</p></div></section>
    <section class="section">
      <div class="container contact-layout">
        <form id="contact-form" class="panel" aria-labelledby="contact-form-title" aria-busy="false">
          <h2 id="contact-form-title">聯絡資料</h2>
          <p><span class="required">*</span> 為必填欄位。</p>
          <div class="form-grid">
            <div class="field"><label for="contact-name">姓名 <span class="required" aria-hidden="true">*</span></label><input id="contact-name" name="name" autocomplete="name" minlength="2" maxlength="80" required></div>
            <div class="field"><label for="contact-gender">性別 <span class="required" aria-hidden="true">*</span></label><select id="contact-gender" name="gender" required><option value="">請選擇</option><option value="女">女性</option><option value="男">男性</option><option value="其他">其他</option><option value="不透露">不透露</option></select></div>
            <div class="field"><label for="contact-phone">電話 <span class="required" aria-hidden="true">*</span></label><input id="contact-phone" name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength="30" required></div>
            <div class="field"><label for="contact-email">信箱 <span class="required" aria-hidden="true">*</span></label><input id="contact-email" name="email" type="email" autocomplete="email" maxlength="254" required></div>
            <div class="field field-full"><label for="contact-message">內容 <span class="required" aria-hidden="true">*</span></label><textarea id="contact-message" name="message" minlength="5" maxlength="2000" required></textarea><small>請勿填寫信用卡、密碼或其他敏感資料。</small></div>
          </div>
          <p id="contact-status" class="status-box" role="status" aria-live="assertive" tabindex="-1"></p>
          <button id="contact-submit" class="button" type="submit">送出聯絡資料</button>
        </form>
        <aside class="panel contact-aside" aria-labelledby="contact-note-title">
          <h2 id="contact-note-title">送出前先知道</h2>
          <p>正式產物預設連線到公開 GAS Web App;在 localhost 開啟時會自動切換成 local mock,避免課堂練習誤寫入 Sheet。</p>
          <p>本機以 <code>?testMode=1</code> 注入測試 endpoint 後,前端會以 <code>URLSearchParams</code> 傳送 <code>action</code> 與 JSON <code>payload</code>,不設定自訂 request header。</p>
          <a href="products.html">先看看商品</a>
        </aside>
      </div>
    </section>
  </main>
  <footer class="site-footer">
    <div class="container footer-inner">
      <div>
        <strong>鐵刻運動</strong>
        <p>把訓練帶進日常,一步一步建立自己的節奏。</p>
      </div>
      <nav aria-label="頁尾導覽">
        <a href="products.html">選購商品</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <p class="footer-note">教學示範網站,不提供付款、登入、庫存扣減或真實出貨。</p>
    </div>
  </footer>
  <script src="js/config.js" defer></script>
  <script src="js/api.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/common.js" defer></script>
  <script src="js/contact.js" defer></script>
</body>
</html>
css/style.css黃色與鐵灰共用樣式:包含響應式版面、focus 與可讀狀態。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

:root {
  color-scheme: light;
  --yellow: #f6c90e;
  --yellow-soft: #fff6c2;
  --yellow-deep: #d8a900;
  --iron-950: #20242a;
  --iron-800: #303841;
  --iron-700: #454d57;
  --iron-500: #68717d;
  --iron-300: #c7ccd2;
  --iron-150: #e7eaed;
  --iron-050: #f6f7f8;
  --white: #ffffff;
  --sale-red: #b42318;
  --shadow: 0 16px 38px rgb(32 36 42 / 0.12);
  --radius-lg: 1.5rem;
  --radius-md: 0.85rem;
  --container: 72rem;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  min-width: 0;
  margin: 0;
  overflow-x: hidden;
  color: var(--iron-950);
  background: var(--iron-050);
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  line-height: 1.65;
}

img,
svg {
  display: block;
  max-width: 100%;
  height: auto;
}

a {
  color: inherit;
  text-underline-offset: 0.22em;
}

button,
input,
select,
textarea {
  max-width: 100%;
  color: inherit;
  font: inherit;
}

button,
.button {
  min-height: 2.75rem;
}

:focus-visible {
  outline: 4px solid var(--yellow);
  outline-offset: 3px;
}

[hidden] {
  display: none !important;
}

.skip-link {
  position: fixed;
  z-index: 100;
  top: 0.5rem;
  left: 0.5rem;
  padding: 0.65rem 1rem;
  color: var(--white);
  background: var(--iron-950);
  border-radius: 0.5rem;
  transform: translateY(-160%);
}

.skip-link:focus {
  transform: translateY(0);
}

.container {
  width: min(100% - 2rem, var(--container));
  margin-inline: auto;
}

.site-header {
  position: sticky;
  z-index: 20;
  top: 0;
  color: var(--white);
  background: var(--iron-800);
  border-bottom: 4px solid var(--yellow);
}

.header-inner {
  display: flex;
  flex-wrap: wrap;
  gap: 0.65rem 1.25rem;
  align-items: center;
  min-height: 5rem;
  padding-block: 0.75rem;
}

.brand {
  display: inline-flex;
  gap: 0.65rem;
  align-items: center;
  font-size: 1.15rem;
  font-weight: 900;
  text-decoration: none;
  white-space: nowrap;
}

.main-nav {
  display: flex;
  flex: 1 1 24rem;
  flex-wrap: wrap;
  gap: 0.25rem;
  justify-content: center;
}

.main-nav a,
.cart-shortcut {
  padding: 0.5rem 0.75rem;
  border-radius: 999px;
  font-weight: 750;
  text-decoration: none;
}

.main-nav a:hover,
.main-nav a[aria-current="page"] {
  color: var(--iron-950);
  background: var(--yellow);
}

.cart-shortcut {
  color: var(--iron-950);
  background: var(--white);
}

.cart-count {
  display: inline-grid;
  min-width: 1.55rem;
  min-height: 1.55rem;
  place-items: center;
  padding-inline: 0.35rem;
  background: var(--yellow);
  border-radius: 999px;
}

.mode-banner {
  min-height: 2.35rem;
  padding: 0.35rem 1rem;
  color: var(--iron-950);
  background: var(--yellow-soft);
  border-bottom: 1px solid var(--yellow-deep);
  text-align: center;
  font-size: 0.92rem;
  font-weight: 750;
}

.mode-banner[data-mode="live"] {
  color: var(--white);
  background: var(--iron-700);
  border-color: var(--iron-950);
}

.hero {
  color: var(--white);
  background:
    linear-gradient(120deg, rgb(32 36 42 / 0.97), rgb(48 56 65 / 0.88)),
    repeating-linear-gradient(135deg, transparent 0 24px, rgb(246 201 14 / 0.2) 24px 28px);
}

.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.8fr);
  gap: clamp(2rem, 6vw, 5rem);
  align-items: center;
  min-height: 34rem;
  padding-block: clamp(3.5rem, 8vw, 7rem);
}

.eyebrow {
  margin: 0 0 0.5rem;
  color: var(--yellow);
  font-weight: 900;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

.hero h1,
.page-hero h1 {
  max-width: 16ch;
  margin: 0;
  font-size: clamp(2.25rem, 8vw, 5rem);
  line-height: 1.05;
}

.hero-copy {
  max-width: 38rem;
  margin-block: 1.25rem 1.75rem;
  color: var(--iron-150);
  font-size: clamp(1.05rem, 2vw, 1.25rem);
}

.hero-card {
  padding: clamp(1.25rem, 4vw, 2.25rem);
  color: var(--iron-950);
  background: var(--yellow);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
  transform: rotate(2deg);
}

.hero-card strong {
  display: block;
  font-size: clamp(1.5rem, 4vw, 2.4rem);
  line-height: 1.15;
}

.hero-card ul {
  padding-left: 1.25rem;
}

.button-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.button {
  display: inline-flex;
  gap: 0.5rem;
  align-items: center;
  justify-content: center;
  padding: 0.7rem 1.1rem;
  color: var(--iron-950);
  background: var(--yellow);
  border: 2px solid var(--yellow);
  border-radius: 0.65rem;
  cursor: pointer;
  font-weight: 850;
  text-align: center;
  text-decoration: none;
}

.button:hover {
  background: var(--yellow-deep);
  border-color: var(--yellow-deep);
}

.button-secondary {
  color: var(--white);
  background: transparent;
  border-color: var(--iron-300);
}

.button-secondary:hover {
  color: var(--iron-950);
  background: var(--white);
  border-color: var(--white);
}

.button-quiet {
  color: var(--iron-950);
  background: var(--white);
  border-color: var(--iron-300);
}

.button-danger {
  color: var(--sale-red);
  background: rgb(180 35 24 / 0.04);
  border: 1px solid var(--sale-red);
  border-radius: 999px;
  font-size: 0.92rem;
  font-weight: 850;
  min-height: 2.35rem;
  padding: 0.45rem 0.85rem;
}

.button-danger:hover {
  color: var(--white);
  background: var(--sale-red);
  border-color: var(--sale-red);
}

.cart-item-actions {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.6rem;
}

.button:disabled,
button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

.section {
  padding-block: clamp(3rem, 7vw, 5.5rem);
}

.section-heading {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 2rem;
  align-items: end;
  justify-content: space-between;
  margin-bottom: 1.5rem;
}

.section-heading h2,
.panel h2 {
  margin: 0;
  font-size: clamp(1.65rem, 4vw, 2.5rem);
  line-height: 1.2;
}

.section-heading p {
  max-width: 42rem;
  margin: 0;
  color: var(--iron-700);
}

.feature-grid,
.product-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.25rem;
}

.feature-card,
.product-card,
.panel {
  min-width: 0;
  background: var(--white);
  border: 1px solid var(--iron-150);
  border-radius: var(--radius-md);
  box-shadow: 0 8px 24px rgb(32 36 42 / 0.07);
}

.feature-card {
  padding: 1.4rem;
}

.feature-card strong {
  display: block;
  margin-bottom: 0.4rem;
  font-size: 1.2rem;
}

.product-card {
  position: relative;
  overflow: hidden;
}

.product-card img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: var(--iron-150);
}

.product-card-body {
  display: grid;
  gap: 0.85rem;
  padding: 1.15rem;
}

.product-card h2,
.product-card h3 {
  margin: 0;
  font-size: 1.25rem;
  line-height: 1.35;
}

.product-meta {
  margin: 0;
  color: var(--iron-700);
  font-size: 0.93rem;
}

.price-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 0.65rem;
  align-items: baseline;
}

.price-sale {
  font-size: 1.35rem;
  font-weight: 900;
}

.price-original {
  color: var(--iron-500);
  text-decoration: line-through;
}

.sale-badge {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
  padding: 0.25rem 0.55rem;
  color: var(--white);
  background: var(--sale-red);
  border-radius: 0.35rem;
  font-weight: 900;
}

.page-hero {
  padding-block: clamp(2.5rem, 6vw, 4.5rem);
  color: var(--white);
  background: var(--iron-800);
}

.page-hero h1 {
  font-size: clamp(2rem, 7vw, 4rem);
}

.page-hero p {
  max-width: 48rem;
  color: var(--iron-150);
}

.toolbar {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(12rem, 1fr);
  gap: 1rem;
  margin-bottom: 1.5rem;
  padding: 1rem;
  background: var(--white);
  border: 1px solid var(--iron-150);
  border-radius: var(--radius-md);
}

.field {
  display: grid;
  min-width: 0;
  gap: 0.35rem;
}

.field label,
.field > span,
legend {
  font-weight: 800;
}

.field input,
.field select,
.field textarea {
  width: 100%;
  padding: 0.7rem 0.8rem;
  background: var(--white);
  border: 2px solid var(--iron-300);
  border-radius: 0.55rem;
}

.field textarea {
  min-height: 9rem;
  resize: vertical;
}

.field small {
  color: var(--iron-700);
}

.required,
.field-error,
.status-error {
  color: var(--sale-red);
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
}

.field-full {
  grid-column: 1 / -1;
}

.panel {
  padding: clamp(1.15rem, 3vw, 2rem);
}

#checkout-submit {
  margin-top: 1.25rem;
}

.status-box {
  min-height: 1.75rem;
  margin-block: 1rem;
  padding: 0.75rem;
  color: var(--iron-950);
  background: var(--yellow-soft);
  border-left: 5px solid var(--yellow);
  border-radius: 0.35rem;
}

.status-box:empty {
  display: none;
}

.status-box.status-error {
  background: var(--white);
  border-color: var(--sale-red);
}

.empty-state {
  padding: 2rem;
  background: var(--white);
  border: 2px dashed var(--iron-300);
  border-radius: var(--radius-md);
  text-align: center;
}

.cart-layout,
.checkout-layout,
.contact-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(17rem, 0.75fr);
  gap: 1.5rem;
  align-items: start;
}

.cart-list {
  display: grid;
  gap: 1rem;
}

.cart-item {
  display: grid;
  grid-template-columns: 8rem minmax(0, 1fr) auto;
  gap: 1rem;
  align-items: center;
  padding: 1rem;
  background: var(--white);
  border: 1px solid var(--iron-150);
  border-radius: var(--radius-md);
}

.cart-item img {
  width: 8rem;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 0.5rem;
}

.cart-item h2 {
  margin: 0 0 0.25rem;
  font-size: 1.15rem;
}

.quantity-control {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
}

.quantity-control button {
  width: 2.75rem;
  padding: 0.35rem;
  background: var(--white);
  border: 2px solid var(--iron-300);
  border-radius: 0.5rem;
  cursor: pointer;
  font-weight: 900;
}

.quantity-control output {
  min-width: 2rem;
  text-align: center;
  font-weight: 900;
}

.summary-list,
.receipt-list {
  display: grid;
  gap: 0.65rem;
  margin: 1rem 0;
}

.summary-row,
.receipt-list > div {
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  border-bottom: 1px solid var(--iron-150);
}

.summary-row-total {
  padding-top: 0.5rem;
  font-size: 1.2rem;
  font-weight: 900;
  border-top: 3px solid var(--iron-800);
}

.authority-note {
  padding: 0.8rem;
  background: var(--yellow-soft);
  border-radius: 0.5rem;
  font-size: 0.92rem;
}

.order-items {
  padding-left: 1.25rem;
}

.order-items li + li {
  margin-top: 0.4rem;
}

.receipt-list dt {
  color: var(--iron-700);
  font-weight: 700;
}

.receipt-list dd {
  margin: 0;
  text-align: right;
  overflow-wrap: anywhere;
}

.status-pill {
  display: inline-flex;
  padding: 0.25rem 0.65rem;
  color: var(--iron-950);
  background: var(--yellow);
  border-radius: 999px;
  font-weight: 900;
}

.contact-aside {
  color: var(--white);
  background: var(--iron-800);
}

.contact-aside a {
  color: var(--yellow);
}

.loading-block {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.8rem;
  min-height: 8rem;
  padding: 2rem;
  color: var(--iron-700);
  background: var(--white);
  border: 1px solid var(--iron-150);
  border-radius: var(--radius-md);
  box-shadow: 0 8px 24px rgb(32 36 42 / 0.07);
  text-align: center;
}

.loading-spinner {
  flex: 0 0 auto;
  width: 1.35rem;
  height: 1.35rem;
  border: 0.22rem solid var(--iron-150);
  border-top-color: var(--yellow-deep);
  border-radius: 50%;
  animation: loading-spin 0.8s linear infinite;
}

@keyframes loading-spin {
  to {
    transform: rotate(360deg);
  }
}

.site-footer {
  padding-block: 2.5rem;
  color: var(--iron-150);
  background: var(--iron-950);
}

.footer-inner {
  display: grid;
  grid-template-columns: 1.1fr 0.6fr 1fr;
  gap: 2rem;
}

.footer-inner p {
  margin-block: 0.4rem 0;
}

.footer-inner nav {
  display: grid;
  align-content: start;
  gap: 0.45rem;
}

.footer-note {
  font-size: 0.9rem;
}

@media (max-width: 52rem) {
  .hero-grid,
  .cart-layout,
  .checkout-layout,
  .contact-layout {
    grid-template-columns: 1fr;
  }

  .feature-grid,
  .product-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .hero-card {
    transform: none;
  }

  .footer-inner {
    grid-template-columns: 1fr 1fr;
  }

  .footer-note {
    grid-column: 1 / -1;
  }
}

@media (max-width: 38rem) {
  .container {
    width: min(100% - 1.25rem, var(--container));
  }

  .site-header {
    position: static;
  }

  .header-inner {
    justify-content: center;
  }

  .main-nav {
    order: 3;
    flex-basis: 100%;
    justify-content: flex-start;
  }

  .main-nav a {
    flex: 1 1 auto;
    padding-inline: 0.55rem;
    text-align: center;
  }

  .cart-shortcut {
    margin-left: auto;
  }

  .feature-grid,
  .product-grid,
  .form-grid,
  .toolbar,
  .footer-inner {
    grid-template-columns: 1fr;
  }

  .field-full,
  .footer-note {
    grid-column: auto;
  }

  .cart-item {
    grid-template-columns: 5.5rem minmax(0, 1fr);
  }

  .cart-item img {
    width: 5.5rem;
  }

  .cart-item-actions {
    grid-column: 1 / -1;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: flex-end;
    width: 100%;
  }

  .button-row .button {
    flex: 1 1 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
  }

  .loading-spinner {
    animation: none;
  }
}
index.html鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="鐵刻運動提供居家重量、有氧與肌力訓練器材。">
  <meta property="og:type" content="website">
  <meta property="og:title" content="首頁|鐵刻運動">
  <meta property="og:description" content="鐵刻運動提供居家重量、有氧與肌力訓練器材。">
  <meta property="og:url" content="https://example.com/project/e-commerce/">
  <title>首頁|鐵刻運動</title>
  <link rel="canonical" href="https://example.com/project/e-commerce/">
  <link rel="icon" href="assets/logo-mark.svg" type="image/svg+xml">
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <a class="skip-link" href="#main-content">跳到主要內容</a>
  <header class="site-header">
    <div class="container header-inner">
      <a class="brand" href="index.html" aria-label="鐵刻運動首頁">
        <img src="assets/logo-mark.svg" width="44" height="44" alt="">
        <span>鐵刻運動</span>
      </a>
      <nav class="main-nav" aria-label="主要導覽">
        <a href="index.html" aria-current="page">首頁</a>
        <a href="products.html">商品</a>
        <a href="cart.html">購物車</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <a class="cart-shortcut" href="cart.html" aria-label="前往購物車">
        購物車 <span class="cart-count" data-cart-count aria-live="polite">0</span>
      </a>
    </div>
  </header>
  <div class="mode-banner" data-mode-banner role="status" aria-live="polite"></div>

  <main id="main-content" tabindex="-1">
    <section class="hero">
      <div class="container hero-grid">
        <div>
          <p class="eyebrow">Train where you live</p>
          <h1>把每一次訓練,練成你的底氣。</h1>
          <p class="hero-copy">從一組啞鈴、一張瑜珈墊開始,打造不受時間限制的居家訓練空間。</p>
          <div class="button-row">
            <a class="button" href="products.html">開始選購</a>
            <a class="button button-secondary" href="#featured">查看精選商品</a>
          </div>
        </div>
        <aside class="hero-card" aria-label="購物重點">
          <strong>滿 NT$2,000<br>免運配送</strong>
          <ul>
            <li>價格與運費先在前端估算</li>
            <li>送單後以伺服器回應為準</li>
            <li>教學模式不會送出個人資料</li>
          </ul>
        </aside>
      </div>
    </section>
    <section class="section" aria-labelledby="benefit-title">
      <div class="container">
        <div class="section-heading">
          <div><p class="eyebrow">Why ironfit</p><h2 id="benefit-title">為日常設計的訓練裝備</h2></div>
        </div>
        <div class="feature-grid">
          <article class="feature-card"><strong>清楚規格</strong><span>每項商品顯示編號、售價與特價,方便比較。</span></article>
          <article class="feature-card"><strong>購物車保留</strong><span>以 localStorage 保存數量,重新整理頁面也能繼續。</span></article>
          <article class="feature-card"><strong>後端核價</strong><span>前端金額只是估算,訂單完成後一律採用後端回傳結果。</span></article>
        </div>
      </div>
    </section>
    <section class="section" id="featured" aria-labelledby="featured-title">
      <div class="container">
        <div class="section-heading">
          <div><p class="eyebrow">Featured gear</p><h2 id="featured-title">精選訓練裝備</h2></div>
          <a href="products.html">查看全部商品</a>
        </div>
        <p id="featured-status" class="status-box" role="status" aria-live="polite"></p>
        <div id="featured-loading" class="loading-block" role="status" aria-live="polite">
          <span class="loading-spinner" aria-hidden="true"></span>
          <span>正在連線 GAS,讀取精選商品…</span>
        </div>
        <div id="featured-products" class="product-grid" aria-busy="true"></div>
      </div>
    </section>
  </main>
  <footer class="site-footer">
    <div class="container footer-inner">
      <div>
        <strong>鐵刻運動</strong>
        <p>把訓練帶進日常,一步一步建立自己的節奏。</p>
      </div>
      <nav aria-label="頁尾導覽">
        <a href="products.html">選購商品</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <p class="footer-note">教學示範網站,不提供付款、登入、庫存扣減或真實出貨。</p>
    </div>
  </footer>
  <script src="js/config.js" defer></script>
  <script src="js/api.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/common.js" defer></script>
  <script src="js/home.js" defer></script>
</body>
</html>
js/api.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";

  var config = window.ECOMMERCE_CONFIG;
  var mockProducts = Object.freeze([
    Object.freeze({ productId: "EQ-DUMBBELL-01", name: "可調式六角啞鈴組", image: "assets/product-dumbbell.svg", price: 2680, salePrice: 2280, active: true }),
    Object.freeze({ productId: "EQ-MAT-02", name: "止滑訓練瑜珈墊", image: "assets/product-mat.svg", price: 1280, salePrice: 980, active: true }),
    Object.freeze({ productId: "EQ-KETTLE-03", name: "鑄鐵競技壺鈴", image: "assets/product-kettlebell.svg", price: 1880, salePrice: 1580, active: true }),
    Object.freeze({ productId: "EQ-BAND-04", name: "五段阻力帶套組", image: "assets/product-band.svg", price: 780, salePrice: 650, active: true }),
    Object.freeze({ productId: "EQ-BIKE-05", name: "靜音磁控健身車", image: "assets/product-bike.svg", price: 12800, salePrice: 10900, active: true }),
    Object.freeze({ productId: "EQ-BENCH-06", name: "多角度訓練椅", image: "assets/product-bench.svg", price: 5680, salePrice: 4980, active: true })
  ]);

  function normalizeProduct(product) {
    return {
      productId: String(product && product.productId || "").trim(),
      name: String(product && product.name || "").trim(),
      image: String(product && product.image || "").trim(),
      price: Number(product && product.price),
      salePrice: product && product.salePrice !== null && product.salePrice !== "" ? Number(product.salePrice) : null,
      active: true
    };
  }

  function wait(ms) {
    return new Promise(function (resolve) { window.setTimeout(resolve, ms); });
  }

  function activePrice(product) {
    return Number(product.salePrice) > 0 && Number(product.salePrice) < Number(product.price)
      ? Number(product.salePrice)
      : Number(product.price);
  }

  function shippingFor(subtotal) {
    if (subtotal <= 0 || subtotal >= config.FREE_SHIPPING_THRESHOLD) return 0;
    return config.SHIPPING_FEE;
  }

  function buildUrl(action) {
    var url = new URL(config.GAS_ENDPOINT);
    url.searchParams.set("action", action);
    return url.toString();
  }

  async function readJson(response) {
    var result;
    try {
      result = await response.json();
    } catch (error) {
      throw new Error("伺服器沒有回傳可讀取的 JSON。");
    }
    if (!response.ok) {
      var httpError = new Error(result.message || "HTTP 請求失敗(" + response.status + ")。");
      httpError.code = result.error || "HTTP_ERROR";
      httpError.status = response.status;
      throw httpError;
    }
    if (!result || result.ok !== true) {
      var applicationError = new Error(result && result.message ? result.message : "應用程式回報失敗。");
      applicationError.code = result && result.error ? result.error : "APPLICATION_ERROR";
      throw applicationError;
    }
    return result;
  }

  async function request(url, options) {
    var controller = new AbortController();
    var timer = window.setTimeout(function () { controller.abort(); }, config.REQUEST_TIMEOUT_MS);
    try {
      return await fetch(url, Object.assign({}, options, { signal: controller.signal }));
    } catch (error) {
      if (error && error.name === "AbortError") throw new Error("GAS 連線逾時(等待超過 30 秒),請稍候再試。");
      throw new Error("無法連線到 GAS Web App,請稍後再試。");
    } finally {
      window.clearTimeout(timer);
    }
  }

  async function getProducts() {
    if (!config.GAS_ENDPOINT) {
      await wait(180);
      return { ok: true, mode: "local-mock", data: { products: mockProducts.map(normalizeProduct) } };
    }
    var response = await request(buildUrl("listProducts"), { method: "GET" });
    var result = await readJson(response);
    if (!result.data || !Array.isArray(result.data.products)) throw new Error("商品回應缺少 data.products 陣列。");
    return Object.assign({}, result, { data: Object.assign({}, result.data, {
      products: result.data.products.map(normalizeProduct)
    }) });
  }

  async function postPayload(action, payload) {
    async function send() {
      var body = new URLSearchParams();
      body.set("action", action);
      body.set("payload", JSON.stringify(payload));
      var response = await request(config.GAS_ENDPOINT, { method: "POST", body: body });
      return readJson(response);
    }

    try {
      return await send();
    } catch (error) {
      var redirectChangedPostToGet = error && error.code === "UNKNOWN_ACTION" && /讀取動作/.test(error.message || "");
      if (!redirectChangedPostToGet) throw error;
      await wait(500);
      return send();
    }
  }

  async function createOrder(payload) {
    if (!config.GAS_ENDPOINT) {
      await wait(280);
      var productsById = new Map(mockProducts.map(function (product) { return [product.productId, product]; }));
      var items = payload.items.map(function (item) {
        var product = productsById.get(item.productId);
        if (!product || !product.active) throw new Error("購物車包含不存在或已下架的商品。");
        var quantity = Math.max(1, Math.floor(Number(item.qty) || 1));
        var unitPrice = activePrice(product);
        return { productId: product.productId, name: product.name, image: product.image, price: product.price, salePrice: product.salePrice, unitPrice: unitPrice, qty: quantity, lineTotal: unitPrice * quantity };
      });
      var subtotal = items.reduce(function (sum, item) { return sum + item.lineTotal; }, 0);
      var shippingFee = shippingFor(subtotal);
      var orderNumber = "MOCK-" + String(Date.now()).slice(-8);
      return {
        ok: true,
        mode: "local-mock",
        message: "教學模擬訂單已建立,未送出 POST,也未寫入 Google Sheet。",
        data: {
          duplicate: false,
          orderNo: orderNumber,
          items: items,
          subtotal: subtotal,
          shipping: shippingFee,
          total: subtotal + shippingFee,
          status: "教學模擬"
        }
      };
    }
    return postPayload("createOrder", payload);
  }

  async function sendContact(payload) {
    if (!config.GAS_ENDPOINT) {
      await wait(280);
      return {
        ok: true,
        mode: "local-mock",
        message: "聯絡資料已完成本機模擬,未送出 POST,也未寫入 Google Sheet。",
        data: { duplicate: false, contactNo: "MOCK-CONTACT-" + String(Date.now()).slice(-8), status: "教學模擬" }
      };
    }
    return postPayload("createContact", payload);
  }

  window.EcommerceApi = Object.freeze({
    getProducts: getProducts,
    createOrder: createOrder,
    sendContact: sendContact,
    activePrice: activePrice,
    shippingFor: shippingFor,
    isMockMode: function () { return !config.GAS_ENDPOINT; }
  });
})();
js/cart.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";
  var list = document.querySelector("#cart-list");
  var empty = document.querySelector("#cart-empty");
  var status = document.querySelector("#cart-status");
  var loading = document.querySelector("#cart-loading");
  var subtotalNode = document.querySelector("#cart-subtotal");
  var shippingNode = document.querySelector("#cart-shipping");
  var totalNode = document.querySelector("#cart-total");
  var checkoutLink = document.querySelector("#checkout-link");
  var products = [];

  function detailedCart() {
    var byId = new Map(products.map(function (product) { return [product.productId, product]; }));
    return window.EcommerceStore.getCart().map(function (cartItem) {
      var product = byId.get(cartItem.productId);
      if (!product || !product.active) return null;
      return { product: product, quantity: cartItem.quantity, lineTotal: window.EcommerceUi.currentPrice(product) * cartItem.quantity };
    }).filter(Boolean);
  }

  function render() {
    var items = detailedCart();
    list.replaceChildren();
    empty.hidden = items.length > 0;
    list.hidden = items.length === 0;
    checkoutLink.hidden = items.length === 0;

    items.forEach(function (item) {
      var article = window.EcommerceUi.element("article", { className: "cart-item" });
      var image = window.EcommerceUi.element("img", { src: item.product.image, alt: "", width: "160", height: "120" });
      var info = window.EcommerceUi.element("div");
      info.append(
        window.EcommerceUi.element("h2", { text: item.product.name }),
        window.EcommerceUi.element("p", { className: "product-meta", text: item.product.productId + "|單價 " + window.EcommerceUi.formatMoney(window.EcommerceUi.currentPrice(item.product)) }),
        window.EcommerceUi.element("strong", { text: "小計 " + window.EcommerceUi.formatMoney(item.lineTotal) })
      );
      var actions = window.EcommerceUi.element("div", { className: "cart-item-actions" });
      var quantity = window.EcommerceUi.element("div", { className: "quantity-control" });
      var minus = window.EcommerceUi.element("button", { type: "button", text: "−", "aria-label": "減少「" + item.product.name + "」數量" });
      var output = window.EcommerceUi.element("output", { text: String(item.quantity), "aria-label": item.product.name + "目前數量" });
      var plus = window.EcommerceUi.element("button", { type: "button", text: "+", "aria-label": "增加「" + item.product.name + "」數量" });
      var remove = window.EcommerceUi.element("button", { type: "button", className: "button button-danger", text: "移除" });
      minus.addEventListener("click", function () { window.EcommerceStore.setQuantity(item.product.productId, item.quantity - 1); });
      plus.addEventListener("click", function () { window.EcommerceStore.setQuantity(item.product.productId, item.quantity + 1); });
      remove.addEventListener("click", function () { window.EcommerceStore.removeItem(item.product.productId); status.textContent = "已從購物車移除「" + item.product.name + "」。"; });
      quantity.append(minus, output, plus);
      actions.append(quantity, remove);
      article.append(image, info, actions);
      list.append(article);
    });

    var subtotal = items.reduce(function (sum, item) { return sum + item.lineTotal; }, 0);
    var shipping = window.EcommerceApi.shippingFor(subtotal);
    subtotalNode.textContent = window.EcommerceUi.formatMoney(subtotal);
    shippingNode.textContent = shipping ? window.EcommerceUi.formatMoney(shipping) : "免運";
    totalNode.textContent = window.EcommerceUi.formatMoney(subtotal + shipping);
  }

  async function start() {
    list.setAttribute("aria-busy", "true");
    loading.hidden = false;
    try {
      var result = await window.EcommerceApi.getProducts();
      products = result.data.products;
      render();
      window.addEventListener("ecommerce:cart-changed", render);
    } catch (error) {
      status.classList.add("status-error");
      status.textContent = error.message;
    } finally {
      list.setAttribute("aria-busy", "false");
      loading.hidden = true;
    }
  }

  start();
})();
js/checkout.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";
  var form = document.querySelector("#checkout-form");
  var status = document.querySelector("#checkout-status");
  var loading = document.querySelector("#checkout-loading");
  var submitButton = document.querySelector("#checkout-submit");
  var itemsNode = document.querySelector("#checkout-items");
  var subtotalNode = document.querySelector("#checkout-subtotal");
  var shippingNode = document.querySelector("#checkout-shipping");
  var totalNode = document.querySelector("#checkout-total");
  var empty = document.querySelector("#checkout-empty");
  var products = [];
  var pendingSubmissionId = "";

  function detailedCart() {
    var byId = new Map(products.map(function (product) { return [product.productId, product]; }));
    return window.EcommerceStore.getCart().map(function (cartItem) {
      var product = byId.get(cartItem.productId);
      if (!product || !product.active) return null;
      return { product: product, quantity: cartItem.quantity, lineTotal: window.EcommerceUi.currentPrice(product) * cartItem.quantity };
    }).filter(Boolean);
  }

  function renderEstimate() {
    var items = detailedCart();
    itemsNode.replaceChildren.apply(itemsNode, items.map(function (item) {
      return window.EcommerceUi.element("li", { text: item.product.name + " × " + item.quantity + ":" + window.EcommerceUi.formatMoney(item.lineTotal) });
    }));
    var subtotal = items.reduce(function (sum, item) { return sum + item.lineTotal; }, 0);
    var shipping = window.EcommerceApi.shippingFor(subtotal);
    subtotalNode.textContent = window.EcommerceUi.formatMoney(subtotal);
    shippingNode.textContent = shipping ? window.EcommerceUi.formatMoney(shipping) : "免運";
    totalNode.textContent = window.EcommerceUi.formatMoney(subtotal + shipping);
    form.hidden = items.length === 0;
    empty.hidden = items.length > 0;
    return items;
  }

  async function submitOrder(event) {
    event.preventDefault();
    if (!form.reportValidity()) return;
    var items = detailedCart();
    if (!items.length) {
      status.classList.add("status-error");
      status.textContent = "購物車沒有可結帳的商品。";
      return;
    }
    var formData = new FormData(form);
    pendingSubmissionId = pendingSubmissionId || (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "order-" + Date.now());
    var payload = {
      submissionId: pendingSubmissionId,
      buyer: String(formData.get("buyer") || "").trim(),
      gender: String(formData.get("gender") || ""),
      phone: String(formData.get("phone") || "").trim(),
      address: String(formData.get("address") || "").trim(),
      email: String(formData.get("email") || "").trim(),
      items: items.map(function (item) { return { productId: item.product.productId, qty: item.quantity }; }),
      clientEstimate: {
        subtotal: items.reduce(function (sum, item) { return sum + item.lineTotal; }, 0)
      }
    };
    status.classList.remove("status-error");
    status.textContent = "正在建立訂單,請稍候…";
    form.setAttribute("aria-busy", "true");
    submitButton.disabled = true;
    try {
      var result = await window.EcommerceApi.createOrder(payload);
      if (!result.data || !result.data.orderNo) throw new Error("訂單回應缺少必要欄位。");
      var receipt = Object.assign({
        buyer: payload.buyer,
        gender: payload.gender,
        phone: payload.phone,
        address: payload.address,
        email: payload.email
      }, result.data, {
        responseMode: result.mode || "live-test",
        responseMessage: result.message || "訂單已建立。"
      });
      window.EcommerceStore.saveLastOrder(receipt);
      window.EcommerceStore.clearCart();
      pendingSubmissionId = "";
      window.location.assign("order-success.html");
    } catch (error) {
      status.classList.add("status-error");
      status.textContent = error.message + " 資料仍保留在畫面上,請檢查後重試。";
    } finally {
      form.setAttribute("aria-busy", "false");
      submitButton.disabled = false;
    }
  }

  async function start() {
    form.setAttribute("aria-busy", "true");
    form.hidden = true;
    loading.hidden = false;
    try {
      var result = await window.EcommerceApi.getProducts();
      products = result.data.products;
      renderEstimate();
      form.addEventListener("submit", submitOrder);
    } catch (error) {
      status.classList.add("status-error");
      status.textContent = error.message;
      form.hidden = true;
    } finally {
      form.setAttribute("aria-busy", "false");
      loading.hidden = true;
    }
  }

  start();
})();
js/common.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";

  var money = new Intl.NumberFormat("zh-TW", { style: "currency", currency: "TWD", maximumFractionDigits: 0 });

  function formatMoney(value) {
    return money.format(Number(value) || 0);
  }

  function element(tagName, options) {
    var node = document.createElement(tagName);
    Object.entries(options || {}).forEach(function (entry) {
      var key = entry[0];
      var value = entry[1];
      if (key === "className") node.className = value;
      else if (key === "text") node.textContent = value;
      else if (key === "dataset") Object.entries(value).forEach(function (dataEntry) { node.dataset[dataEntry[0]] = dataEntry[1]; });
      else node.setAttribute(key, value);
    });
    return node;
  }

  function currentPrice(product) {
    return window.EcommerceApi.activePrice(product);
  }

  function createProductCard(product, headingLevel) {
    var card = element("article", { className: "product-card" });
    var image = element("img", { src: product.image, alt: product.name, width: "640", height: "480" });
    var body = element("div", { className: "product-card-body" });
    var title = element(headingLevel || "h2", { text: product.name });
    var meta = element("p", { className: "product-meta", text: "商品編號 " + product.productId });
    var prices = element("p", { className: "price-row" });
    prices.append(element("strong", { className: "price-sale", text: formatMoney(currentPrice(product)) }));
    if (Number(product.salePrice) > 0 && Number(product.salePrice) < Number(product.price)) {
      prices.append(element("span", { className: "price-original", text: "原價 " + formatMoney(product.price) }));
      card.append(element("span", { className: "sale-badge", text: "特價" }));
    }
    var button = element("button", { className: "button", type: "button", text: "加入購物車", dataset: { addProduct: product.productId } });
    button.addEventListener("click", function () {
      window.EcommerceStore.addItem(product.productId, 1);
      button.textContent = "已加入購物車";
      window.setTimeout(function () { button.textContent = "加入購物車"; }, 1200);
    });
    body.append(title, meta, prices, button);
    card.append(image, body);
    return card;
  }

  function updateCartCounts() {
    var count = window.EcommerceStore.countItems();
    document.querySelectorAll("[data-cart-count]").forEach(function (node) { node.textContent = String(count); });
  }

  function renderModeBanner() {
    var banner = document.querySelector("[data-mode-banner]");
    if (!banner) return;
    var mock = window.EcommerceApi.isMockMode();
    var mode = window.ECOMMERCE_CONFIG.MODE;
    banner.dataset.mode = mock ? "mock" : "live";
    banner.textContent = mock
      ? "本機教學模式:使用 local mock;不會送出 POST 或寫入 Sheet。"
      : mode === "live-test"
        ? "本機測試連線模式:資料會傳到注入的 GAS 測試 endpoint,請勿使用正式個資。"
        : "已連線至公開 GAS Web App;請勿輸入真實個資或付款資料。";
  }

  window.EcommerceUi = Object.freeze({
    formatMoney: formatMoney,
    element: element,
    createProductCard: createProductCard,
    currentPrice: currentPrice
  });

  window.addEventListener("ecommerce:cart-changed", updateCartCounts);
  updateCartCounts();
  renderModeBanner();
})();
js/config.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";

  var DEFAULT_CONFIG = Object.freeze({
    GAS_ENDPOINT: "https://script.google.com/macros/s/AKfycbzuket8nVayfM8MKhrPguU3ggt-ux8J54W0WuI_bBoLvYa7vc3YS1pQAbtnBO-j-iif/exec",
    REQUEST_TIMEOUT_MS: 30000,
    SHIPPING_FEE: 120,
    FREE_SHIPPING_THRESHOLD: 2000
  });

  function isLocalTestHost() {
    return ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
  }

  function getInjectedEndpoint() {
    var params = new URLSearchParams(window.location.search);
    var testModeEnabled = params.get("testMode") === "1";
    if (!isLocalTestHost() || !testModeEnabled) return "";
    return typeof window.__ECOMMERCE_TEST_ENDPOINT__ === "string"
      ? window.__ECOMMERCE_TEST_ENDPOINT__.trim()
      : "";
  }

  var testModeEnabled = new URLSearchParams(window.location.search).get("testMode") === "1";
  var injectedEndpoint = getInjectedEndpoint();
  var localMockMode = isLocalTestHost() && (!testModeEnabled || !injectedEndpoint);
  window.ECOMMERCE_CONFIG = Object.freeze(Object.assign({}, DEFAULT_CONFIG, {
    GAS_ENDPOINT: localMockMode ? "" : (injectedEndpoint || DEFAULT_CONFIG.GAS_ENDPOINT),
    MODE: localMockMode ? "local-mock" : (injectedEndpoint ? "live-test" : "live")
  }));
})();
js/contact.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";
  var form = document.querySelector("#contact-form");
  var status = document.querySelector("#contact-status");
  var submitButton = document.querySelector("#contact-submit");
  var pendingSubmissionId = "";

  form.addEventListener("submit", async function (event) {
    event.preventDefault();
    if (!form.reportValidity()) return;
    var formData = new FormData(form);
    pendingSubmissionId = pendingSubmissionId || (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "contact-" + Date.now());
    var payload = {
      submissionId: pendingSubmissionId,
      name: String(formData.get("name") || "").trim(),
      gender: String(formData.get("gender") || ""),
      phone: String(formData.get("phone") || "").trim(),
      email: String(formData.get("email") || "").trim(),
      message: String(formData.get("message") || "").trim()
    };
    status.classList.remove("status-error");
    status.textContent = "正在送出聯絡資料,請稍候…";
    form.setAttribute("aria-busy", "true");
    submitButton.disabled = true;
    try {
      var result = await window.EcommerceApi.sendContact(payload);
      if (!result.data || !result.data.contactNo) throw new Error("聯絡回應缺少 data.contactNo。");
      status.textContent = result.message + " 聯絡編號:" + result.data.contactNo;
      pendingSubmissionId = "";
      form.reset();
      status.focus();
    } catch (error) {
      status.classList.add("status-error");
      status.textContent = error.message + " 表單內容仍保留,請檢查後重試。";
    } finally {
      form.setAttribute("aria-busy", "false");
      submitButton.disabled = false;
    }
  });
})();
js/home.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";
  var grid = document.querySelector("#featured-products");
  var status = document.querySelector("#featured-status");
  var loading = document.querySelector("#featured-loading");

  async function loadFeatured() {
    grid.setAttribute("aria-busy", "true");
    loading.hidden = false;
    try {
      var result = await window.EcommerceApi.getProducts();
      var products = result.data.products.filter(function (product) { return product.active; }).slice(0, 3);
      grid.replaceChildren.apply(grid, products.map(function (product) {
        return window.EcommerceUi.createProductCard(product, "h3");
      }));
      status.textContent = "已顯示 " + products.length + " 項精選商品。";
    } catch (error) {
      status.classList.add("status-error");
      status.textContent = error.message;
    } finally {
      grid.setAttribute("aria-busy", "false");
      loading.hidden = true;
    }
  }

  loadFeatured();
})();
js/order-success.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";
  var receipt = window.EcommerceStore.getLastOrder();
  var content = document.querySelector("#order-receipt");
  var missing = document.querySelector("#order-missing");

  function setText(selector, value) {
    var node = document.querySelector(selector);
    if (node) node.textContent = value;
  }

  if (!receipt || !receipt.orderNo) {
    content.hidden = true;
    missing.hidden = false;
    return;
  }

  missing.hidden = true;
  content.hidden = false;
  setText("#receipt-number", receipt.orderNo);
  setText("#receipt-name", receipt.buyer);
  setText("#receipt-gender", receipt.gender);
  setText("#receipt-phone", receipt.phone);
  setText("#receipt-address", receipt.address);
  setText("#receipt-email", receipt.email);
  setText("#receipt-subtotal", window.EcommerceUi.formatMoney(receipt.subtotal));
  setText("#receipt-shipping", Number(receipt.shipping) ? window.EcommerceUi.formatMoney(receipt.shipping) : "免運");
  setText("#receipt-total", window.EcommerceUi.formatMoney(receipt.total));
  setText("#receipt-status", receipt.status);
  setText("#receipt-message", receipt.responseMessage);
  setText("#receipt-authority", receipt.responseMode === "local-mock"
    ? "以上金額與狀態來自本機 mock API 回應;沒有送出 POST 或寫入 Sheet。"
    : receipt.responseMode === "live-test"
      ? "以上金額與狀態已使用 GAS 測試 endpoint 回應覆蓋前端估算。"
      : "以上金額與狀態已使用公開 GAS Web App 回應覆蓋前端估算。"
  );
  var items = Array.isArray(receipt.items) ? receipt.items : [];
  var itemList = document.querySelector("#receipt-items");
  itemList.replaceChildren.apply(itemList, items.map(function (item) {
    return window.EcommerceUi.element("li", { text: item.name + " × " + item.qty + ":" + window.EcommerceUi.formatMoney(item.lineTotal) });
  }));
})();
js/products.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";
  var grid = document.querySelector("#product-grid");
  var status = document.querySelector("#product-status");
  var loading = document.querySelector("#product-loading");
  var search = document.querySelector("#product-search");
  var products = [];

  function render() {
    var keyword = search.value.trim().toLocaleLowerCase("zh-Hant");
    var visible = products.filter(function (product) {
      var matchesKeyword = !keyword || (product.name + " " + product.productId).toLocaleLowerCase("zh-Hant").includes(keyword);
      return product.active && matchesKeyword;
    });
    grid.replaceChildren.apply(grid, visible.map(function (product) {
      return window.EcommerceUi.createProductCard(product, "h2");
    }));
    status.textContent = visible.length ? "找到 " + visible.length + " 項商品。" : "沒有符合條件的商品,請調整搜尋條件。";
  }

  async function loadProducts() {
    grid.setAttribute("aria-busy", "true");
    loading.hidden = false;
    search.disabled = true;
    try {
      var result = await window.EcommerceApi.getProducts();
      products = result.data.products;
      render();
    } catch (error) {
      status.classList.add("status-error");
      status.textContent = error.message;
    } finally {
      grid.setAttribute("aria-busy", "false");
      loading.hidden = true;
      search.disabled = false;
    }
  }

  search.addEventListener("input", render);
  loadProducts();
})();
js/store.js原生 JavaScript:負責商品、購物車、表單、local mock 或 GAS 測試契約。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

(function () {
  "use strict";

  var CART_KEY = "ironfit-ecommerce-cart-v1";
  var ORDER_KEY = "ironfit-ecommerce-last-order-v1";
  var memoryCart = [];
  var memoryOrder = null;

  function readJson(key, fallback) {
    try {
      var raw = window.localStorage.getItem(key);
      return raw ? JSON.parse(raw) : fallback;
    } catch (error) {
      return fallback;
    }
  }

  function writeJson(key, value) {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (error) {
      return false;
    }
  }

  function normalizeCart(value) {
    if (!Array.isArray(value)) return [];
    return value.filter(function (item) {
      return item && typeof item.productId === "string" && Number(item.quantity) > 0;
    }).map(function (item) {
      return { productId: item.productId, quantity: Math.min(99, Math.max(1, Math.floor(Number(item.quantity)))) };
    });
  }

  function getCart() {
    var stored = normalizeCart(readJson(CART_KEY, memoryCart));
    memoryCart = stored;
    return stored.map(function (item) { return Object.assign({}, item); });
  }

  function notify(cart) {
    window.dispatchEvent(new CustomEvent("ecommerce:cart-changed", { detail: { cart: cart } }));
  }

  function saveCart(cart) {
    var normalized = normalizeCart(cart);
    memoryCart = normalized;
    writeJson(CART_KEY, normalized);
    notify(normalized);
    return getCart();
  }

  function addItem(productId, quantity) {
    var cart = getCart();
    var existing = cart.find(function (item) { return item.productId === productId; });
    var amount = Math.max(1, Math.floor(Number(quantity) || 1));
    if (existing) existing.quantity = Math.min(99, existing.quantity + amount);
    else cart.push({ productId: productId, quantity: amount });
    return saveCart(cart);
  }

  function setQuantity(productId, quantity) {
    var amount = Math.floor(Number(quantity) || 0);
    if (amount <= 0) return removeItem(productId);
    return saveCart(getCart().map(function (item) {
      return item.productId === productId ? { productId: productId, quantity: Math.min(99, amount) } : item;
    }));
  }

  function removeItem(productId) {
    return saveCart(getCart().filter(function (item) { return item.productId !== productId; }));
  }

  function clearCart() {
    return saveCart([]);
  }

  function countItems() {
    return getCart().reduce(function (sum, item) { return sum + item.quantity; }, 0);
  }

  function saveLastOrder(order) {
    memoryOrder = order;
    writeJson(ORDER_KEY, order);
  }

  function getLastOrder() {
    return readJson(ORDER_KEY, memoryOrder);
  }

  window.addEventListener("storage", function (event) {
    if (event.key === CART_KEY) notify(getCart());
  });

  window.EcommerceStore = Object.freeze({
    getCart: getCart,
    addItem: addItem,
    setQuantity: setQuantity,
    removeItem: removeItem,
    clearCart: clearCart,
    countItems: countItems,
    saveLastOrder: saveLastOrder,
    getLastOrder: getLastOrder
  });
})();
order-success.html鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="查看鐵刻運動訂單編號、購買人、金額與狀態。">
  <meta property="og:type" content="website">
  <meta property="og:title" content="訂單結果|鐵刻運動">
  <meta property="og:description" content="查看鐵刻運動訂單編號、購買人、金額與狀態。">
  <meta property="og:url" content="https://example.com/project/e-commerce/order-success.html">
  <title>訂單結果|鐵刻運動</title>
  <link rel="canonical" href="https://example.com/project/e-commerce/order-success.html">
  <link rel="icon" href="assets/logo-mark.svg" type="image/svg+xml">
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <a class="skip-link" href="#main-content">跳到主要內容</a>
  <header class="site-header">
    <div class="container header-inner">
      <a class="brand" href="index.html" aria-label="鐵刻運動首頁">
        <img src="assets/logo-mark.svg" width="44" height="44" alt="">
        <span>鐵刻運動</span>
      </a>
      <nav class="main-nav" aria-label="主要導覽">
        <a href="index.html">首頁</a>
        <a href="products.html">商品</a>
        <a href="cart.html" aria-current="page">購物車</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <a class="cart-shortcut" href="cart.html" aria-label="前往購物車">
        購物車 <span class="cart-count" data-cart-count aria-live="polite">0</span>
      </a>
    </div>
  </header>
  <div class="mode-banner" data-mode-banner role="status" aria-live="polite"></div>

  <main id="main-content" tabindex="-1">
    <section class="page-hero"><div class="container"><p class="eyebrow">Order result</p><h1>訂單結果</h1><p>本頁只採用 API 回應中的訂單欄位,不沿用前端估算作為成交金額。</p></div></section>
    <section class="section">
      <div class="container">
        <div id="order-missing" class="empty-state" hidden><h2>目前沒有可顯示的訂單</h2><p>完成結帳後,訂單回應會暫存在此瀏覽器。</p><a class="button" href="products.html">前往商品頁</a></div>
        <article id="order-receipt" class="panel" aria-labelledby="receipt-title">
          <p class="eyebrow">Server-authoritative receipt</p>
          <h2 id="receipt-title">訂單 <span id="receipt-number"></span></h2>
          <p><span class="status-pill" id="receipt-status"></span></p>
          <p id="receipt-message" class="status-box" role="status"></p>
          <dl class="receipt-list">
            <div><dt>購買人</dt><dd id="receipt-name"></dd></div>
            <div><dt>性別</dt><dd id="receipt-gender"></dd></div>
            <div><dt>電話</dt><dd id="receipt-phone"></dd></div>
            <div><dt>地址</dt><dd id="receipt-address"></dd></div>
            <div><dt>信箱</dt><dd id="receipt-email"></dd></div>
            <div><dt>商品總額</dt><dd id="receipt-subtotal"></dd></div>
            <div><dt>運費</dt><dd id="receipt-shipping"></dd></div>
            <div><dt>訂單總額</dt><dd id="receipt-total"></dd></div>
          </dl>
          <h3>訂購商品</h3><ul id="receipt-items" class="order-items"></ul>
          <p id="receipt-authority" class="authority-note"></p>
          <div class="button-row"><a class="button" href="products.html">繼續選購</a><a class="button button-quiet" href="contact.html">聯絡我們</a></div>
        </article>
      </div>
    </section>
  </main>
  <footer class="site-footer">
    <div class="container footer-inner">
      <div>
        <strong>鐵刻運動</strong>
        <p>把訓練帶進日常,一步一步建立自己的節奏。</p>
      </div>
      <nav aria-label="頁尾導覽">
        <a href="products.html">選購商品</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <p class="footer-note">教學示範網站,不提供付款、登入、庫存扣減或真實出貨。</p>
    </div>
  </footer>
  <script src="js/config.js" defer></script>
  <script src="js/api.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/common.js" defer></script>
  <script src="js/order-success.js" defer></script>
</body>
</html>
products.html鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="瀏覽鐵刻運動的居家重量、有氧與肌力訓練器材。">
  <meta property="og:type" content="website">
  <meta property="og:title" content="商品|鐵刻運動">
  <meta property="og:description" content="瀏覽鐵刻運動的居家重量、有氧與肌力訓練器材。">
  <meta property="og:url" content="https://example.com/project/e-commerce/products.html">
  <title>商品|鐵刻運動</title>
  <link rel="canonical" href="https://example.com/project/e-commerce/products.html">
  <link rel="icon" href="assets/logo-mark.svg" type="image/svg+xml">
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <a class="skip-link" href="#main-content">跳到主要內容</a>
  <header class="site-header">
    <div class="container header-inner">
      <a class="brand" href="index.html" aria-label="鐵刻運動首頁">
        <img src="assets/logo-mark.svg" width="44" height="44" alt="">
        <span>鐵刻運動</span>
      </a>
      <nav class="main-nav" aria-label="主要導覽">
        <a href="index.html">首頁</a>
        <a href="products.html" aria-current="page">商品</a>
        <a href="cart.html">購物車</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <a class="cart-shortcut" href="cart.html" aria-label="前往購物車">
        購物車 <span class="cart-count" data-cart-count aria-live="polite">0</span>
      </a>
    </div>
  </header>
  <div class="mode-banner" data-mode-banner role="status" aria-live="polite"></div>

  <main id="main-content" tabindex="-1">
    <section class="page-hero"><div class="container"><p class="eyebrow">Equipment</p><h1>訓練商品</h1><p>依名稱或商品編號找到器材,加入購物車後可調整數量。</p></div></section>
    <section class="section" aria-labelledby="catalog-title">
      <div class="container">
        <h2 id="catalog-title">商品目錄</h2>
        <div class="toolbar" role="search">
          <div class="field"><label for="product-search">搜尋名稱或商品編號</label><input id="product-search" name="productSearch" type="search" autocomplete="off" placeholder="例如:啞鈴"></div>
        </div>
        <p id="product-status" class="status-box" role="status" aria-live="polite"></p>
        <div id="product-loading" class="loading-block" role="status" aria-live="polite">
          <span class="loading-spinner" aria-hidden="true"></span>
          <span>正在連線 GAS,讀取商品資料…</span>
        </div>
        <div id="product-grid" class="product-grid" aria-busy="true"></div>
      </div>
    </section>
  </main>
  <footer class="site-footer">
    <div class="container footer-inner">
      <div>
        <strong>鐵刻運動</strong>
        <p>把訓練帶進日常,一步一步建立自己的節奏。</p>
      </div>
      <nav aria-label="頁尾導覽">
        <a href="products.html">選購商品</a>
        <a href="contact.html">聯絡我們</a>
      </nav>
      <p class="footer-note">教學示範網站,不提供付款、登入、庫存扣減或真實出貨。</p>
    </div>
  </footer>
  <script src="js/config.js" defer></script>
  <script src="js/api.js" defer></script>
  <script src="js/store.js" defer></script>
  <script src="js/common.js" defer></script>
  <script src="js/products.js" defer></script>
</body>
</html>
robots.txt鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

User-agent: *
Allow: /

Sitemap: https://example.com/project/e-commerce/sitemap.xml
sitemap.xml鐵刻運動電商頁面或發佈檔案。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://example.com/project/e-commerce/</loc></url>
  <url><loc>https://example.com/project/e-commerce/products.html</loc></url>
  <url><loc>https://example.com/project/e-commerce/cart.html</loc></url>
  <url><loc>https://example.com/project/e-commerce/checkout.html</loc></url>
  <url><loc>https://example.com/project/e-commerce/order-success.html</loc></url>
  <url><loc>https://example.com/project/e-commerce/contact.html</loc></url>
</urlset>
smoke-test.md發佈前後 smoke checklist:不代表已部署。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

# 鐵刻運動|Release smoke checklist

> 本檔是部署前與部署後的驗收清單,不代表網站已部署。
>
> `https://example.com/project/e-commerce/` 是 placeholder,部署前必須替換成實際測試或正式網址。

## 本機產物

- [ ] 六頁皆能開啟:首頁、商品、購物車、結帳、訂單結果、聯絡我們。
- [ ] 所有 HTML 的 CSS、JavaScript、圖片與頁面相對路徑都回應成功,沒有遺失的本地 reference。
- [ ] 桌機與 390px viewport 都沒有水平捲軸,內容與控制項不被裁切。
- [ ] 只用 Tab/Shift+Tab/Enter/Space 可到達並操作主要導覽、商品、購物車與表單,focus 外框清楚。
- [ ] Console 沒有未處理錯誤;Network 沒有非預期 404。

## 資料流程

- [ ] localhost 未開啟 testMode 時明確顯示 local mock,商品、訂單、聯絡流程不送出 POST。
- [ ] 正式產物預設使用公開 GAS Web App;畫面明確提醒勿輸入真實個資或付款資料。
- [ ] 注入 GAS 測試 endpoint 後,Network 的 GET action 是 `listProducts`,POST 使用 form-urlencoded `action`+JSON `payload`。
- [ ] loading、success、error、timeout 都可觀察;錯誤與 timeout 時輸入資料保留,可用相同 submissionId 重試。
- [ ] 訂單成功頁的商品總額、運費、訂單總額與狀態來自 server response,不採信前端估算。
- [ ] 相同 submissionId 重送時,測試 Sheet 沒有重複列。

## 發佈與回復

- [ ] 將六頁 canonical URL、Open Graph 的 og:url、robots.txt 與 sitemap.xml 中所有 example.com placeholder 全部替換成實際網域後再發佈。
- [ ] 記錄本次 artifact hash、部署時間、上一版 deployment ID 與 rollback 步驟。
- [ ] 發佈後用公開 URL 重跑六頁、390px、Tab、Console、404、mock/live Network 與 server totals。
- [ ] 若 smoke 失敗,切回上一版 artifact/deployment,保留失敗 Network 與 Console 證據後再修正。
apps-script/Code.gsGAS 測試後端:展示商品 GET、訂單/聯絡 POST、Sheet schema、權威計價與去重。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

var SHEET_NAMES_ = Object.freeze({
  products: "Products",
  orders: "Orders",
  contacts: "Contacts"
});

var HEADERS_ = Object.freeze({
  Products: Object.freeze(["productId", "name", "image", "price", "salePrice", "active"]),
  Orders: Object.freeze(["orderNo", "submissionId", "timestamp", "buyer", "gender", "phone", "address", "email", "subtotal", "shipping", "total", "status", "itemsJson"]),
  Contacts: Object.freeze(["contactNo", "submissionId", "timestamp", "name", "gender", "phone", "email", "message", "status"])
});

var TEST_SPREADSHEET_PROPERTY_ = "TEST_SPREADSHEET_ID";
var TIME_ZONE_ = "Asia/Taipei";
var FREE_SHIPPING_THRESHOLD_ = 2000;
var SHIPPING_FEE_ = 120;
var ORDER_STATUS_ = "待確認";
var CONTACT_STATUS_ = "未處理";
var ALLOWED_GENDERS_ = Object.freeze(["女", "男", "其他", "不透露"]);

function doGet(e) {
  try {
    var action = normalizeText_(e && e.parameter ? e.parameter.action : "");
    if (action !== "listProducts") {
      fail_("UNKNOWN_ACTION", "不支援的讀取動作");
    }

    var catalog = loadProductCatalog_(getTestSpreadsheet_());
    var products = catalog.products.filter(function (product) {
      return product.active;
    }).map(function (product) {
      return {
        productId: product.productId,
        name: product.name,
        image: product.image,
        price: product.price,
        salePrice: product.salePrice
      };
    });

    return jsonResponse_({
      ok: true,
      data: { products: products },
      message: "商品載入成功"
    });
  } catch (error) {
    return errorResponse_(error);
  }
}

function doPost(e) {
  try {
    var request = parsePostRequest_(e);
    if (request.action === "createOrder") {
      return jsonResponse_(createOrder_(request.payload));
    }
    if (request.action === "createContact") {
      return jsonResponse_(createContact_(request.payload));
    }
    fail_("UNKNOWN_ACTION", "不支援的寫入動作");
  } catch (error) {
    return errorResponse_(error);
  }
}

function parsePostRequest_(e) {
  var parameters = e && e.parameter ? e.parameter : {};
  var action = normalizeText_(parameters.action);
  var rawPayload = typeof parameters.payload === "string" ? parameters.payload : "";

  if (!action) fail_("MISSING_ACTION", "缺少 action");
  if (!rawPayload) fail_("MISSING_PAYLOAD", "缺少 payload");
  if (rawPayload.length > 30000) fail_("PAYLOAD_TOO_LARGE", "payload 超過允許長度");

  var payload;
  try {
    payload = JSON.parse(rawPayload);
  } catch (error) {
    fail_("INVALID_JSON", "payload 必須是有效 JSON");
  }

  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    fail_("INVALID_PAYLOAD", "payload 必須是 JSON object");
  }
  if (normalizeText_(parameters.website) || normalizeText_(payload.website)) {
    fail_("HONEYPOT", "表單驗證失敗");
  }

  return { action: action, payload: payload };
}

function createOrder_(payload) {
  var input = validateOrderInput_(payload);
  var spreadsheet = getTestSpreadsheet_();
  var ordersSheet = requireSheet_(spreadsheet, SHEET_NAMES_.orders);
  var lock = LockService.getScriptLock();

  if (!lock.tryLock(10000)) fail_("SERVER_BUSY", "系統忙碌中,請稍後使用相同 submissionId 重試");
  try {
    var existing = findBySubmissionId_(ordersSheet, input.submissionId);
    if (existing) {
      return {
        ok: true,
        data: {
          duplicate: true,
          orderNo: String(existing[0]),
          subtotal: Number(existing[8]),
          shipping: Number(existing[9]),
          total: Number(existing[10]),
          status: String(existing[11]),
          items: parseStoredOrderItems_(existing[12])
        },
        message: "此訂單已接收,未重複寫入"
      };
    }

    var catalog = loadProductCatalog_(spreadsheet);
    var pricing = calculateAuthoritativeOrder_(input.items, catalog.byId);
    var now = new Date();
    var orderNo = makeNumber_("ORD", now);

    ordersSheet.appendRow([
      orderNo,
      input.submissionId,
      now.toISOString(),
      safeSheetText_(input.buyer),
      input.gender,
      safeSheetText_(input.phone),
      safeSheetText_(input.address),
      safeSheetText_(input.email),
      pricing.subtotal,
      pricing.shipping,
      pricing.total,
      ORDER_STATUS_,
      JSON.stringify(pricing.items)
    ]);

    return {
      ok: true,
      data: {
        duplicate: false,
        orderNo: orderNo,
        subtotal: pricing.subtotal,
        shipping: pricing.shipping,
        total: pricing.total,
        status: ORDER_STATUS_,
        items: pricing.items
      },
      message: "測試訂單已建立"
    };
  } finally {
    lock.releaseLock();
  }
}

function createContact_(payload) {
  var input = validateContactInput_(payload);
  var spreadsheet = getTestSpreadsheet_();
  var contactsSheet = requireSheet_(spreadsheet, SHEET_NAMES_.contacts);
  var lock = LockService.getScriptLock();

  if (!lock.tryLock(10000)) fail_("SERVER_BUSY", "系統忙碌中,請稍後使用相同 submissionId 重試");
  try {
    var existing = findBySubmissionId_(contactsSheet, input.submissionId);
    if (existing) {
      return {
        ok: true,
        data: {
          duplicate: true,
          contactNo: String(existing[0]),
          status: String(existing[8])
        },
        message: "此聯絡訊息已接收,未重複寫入"
      };
    }

    var now = new Date();
    var contactNo = makeNumber_("CON", now);
    contactsSheet.appendRow([
      contactNo,
      input.submissionId,
      now.toISOString(),
      safeSheetText_(input.name),
      input.gender,
      safeSheetText_(input.phone),
      safeSheetText_(input.email),
      safeSheetText_(input.message),
      CONTACT_STATUS_
    ]);

    return {
      ok: true,
      data: {
        duplicate: false,
        contactNo: contactNo,
        status: CONTACT_STATUS_
      },
      message: "測試聯絡訊息已建立"
    };
  } finally {
    lock.releaseLock();
  }
}

function validateOrderInput_(payload) {
  var submissionId = requiredText_(payload, "submissionId", 8, 100);
  validateSubmissionId_(submissionId);

  return {
    submissionId: submissionId,
    buyer: requiredText_(payload, "buyer", 2, 80),
    gender: validateGender_(requiredText_(payload, "gender", 1, 10)),
    phone: validatePhone_(requiredText_(payload, "phone", 8, 24)),
    address: requiredText_(payload, "address", 5, 200),
    email: validateEmail_(requiredText_(payload, "email", 5, 160)),
    items: validateItems_(payload.items)
  };
}

function validateContactInput_(payload) {
  var submissionId = requiredText_(payload, "submissionId", 8, 100);
  validateSubmissionId_(submissionId);

  return {
    submissionId: submissionId,
    name: requiredText_(payload, "name", 2, 80),
    gender: validateGender_(requiredText_(payload, "gender", 1, 10)),
    phone: validatePhone_(requiredText_(payload, "phone", 8, 24)),
    email: validateEmail_(requiredText_(payload, "email", 5, 160)),
    message: requiredText_(payload, "message", 5, 2000)
  };
}

function validateItems_(items) {
  if (!Array.isArray(items) || items.length < 1 || items.length > 50) {
    fail_("INVALID_ITEMS", "商品項目數必須介於 1 到 50");
  }

  var quantities = {};
  var order = [];
  items.forEach(function (item) {
    if (!item || typeof item !== "object" || Array.isArray(item)) {
      fail_("INVALID_ITEM", "每筆商品必須是 object");
    }
    var productId = requiredText_(item, "productId", 1, 64);
    if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(productId)) {
      fail_("INVALID_PRODUCT_ID", "商品編號格式錯誤");
    }
    if (!Number.isInteger(item.qty) || item.qty < 1 || item.qty > 20) {
      fail_("INVALID_QTY", "商品數量必須是 1 到 20 的整數");
    }
    if (!Object.prototype.hasOwnProperty.call(quantities, productId)) order.push(productId);
    quantities[productId] = (quantities[productId] || 0) + item.qty;
    if (quantities[productId] > 20) {
      fail_("INVALID_QTY", "同一商品合計數量不得超過 20");
    }
  });

  return order.map(function (productId) {
    return { productId: productId, qty: quantities[productId] };
  });
}

function calculateAuthoritativeOrder_(items, productsById) {
  var subtotal = 0;
  var snapshots = items.map(function (item) {
    var product = productsById[item.productId];
    if (!product) fail_("UNKNOWN_PRODUCT", "訂單包含不存在的商品");
    if (!product.active) fail_("PRODUCT_INACTIVE", "訂單包含已停售的商品");

    var unitPrice = product.salePrice === null ? product.price : product.salePrice;
    var lineTotal = unitPrice * item.qty;
    if (!Number.isSafeInteger(lineTotal)) fail_("TOTAL_OUT_OF_RANGE", "商品金額超出允許範圍");
    subtotal += lineTotal;
    if (!Number.isSafeInteger(subtotal) || subtotal > 10000000) {
      fail_("TOTAL_OUT_OF_RANGE", "訂單金額超出允許範圍");
    }

    return {
      productId: product.productId,
      name: product.name,
      image: product.image,
      price: product.price,
      salePrice: product.salePrice,
      unitPrice: unitPrice,
      qty: item.qty,
      lineTotal: lineTotal
    };
  });

  var shipping = subtotal >= FREE_SHIPPING_THRESHOLD_ ? 0 : SHIPPING_FEE_;
  return {
    items: snapshots,
    subtotal: subtotal,
    shipping: shipping,
    total: subtotal + shipping
  };
}

function loadProductCatalog_(spreadsheet) {
  var sheet = requireSheet_(spreadsheet, SHEET_NAMES_.products);
  var lastRow = sheet.getLastRow();
  var rows = lastRow < 2 ? [] : sheet.getRange(2, 1, lastRow - 1, HEADERS_.Products.length).getValues();
  var products = [];
  var byId = {};

  rows.forEach(function (row, index) {
    if (row.every(function (value) { return normalizeText_(value) === ""; })) return;
    var rowNumber = index + 2;
    var productId = normalizeText_(row[0]);
    var name = normalizeText_(row[1]);
    var image = normalizeText_(row[2]);
    if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(productId)) {
      fail_("PRODUCT_CONFIG_ERROR", "Products 第 " + rowNumber + " 列 productId 格式錯誤");
    }
    if (!name || name.length > 120) {
      fail_("PRODUCT_CONFIG_ERROR", "Products 第 " + rowNumber + " 列 name 格式錯誤");
    }
    if (Object.prototype.hasOwnProperty.call(byId, productId)) {
      fail_("PRODUCT_CONFIG_ERROR", "Products 有重複 productId");
    }

    var price = productMoney_(row[3], false, "price", rowNumber);
    var salePrice = productMoney_(row[4], true, "salePrice", rowNumber);
    if (salePrice !== null && salePrice >= price) {
      fail_("PRODUCT_CONFIG_ERROR", "salePrice 必須低於 price");
    }

    var product = {
      productId: productId,
      name: name,
      image: image,
      price: price,
      salePrice: salePrice,
      active: activeValue_(row[5])
    };
    products.push(product);
    byId[productId] = product;
  });

  return { products: products, byId: byId };
}

function productMoney_(value, allowBlank, field, rowNumber) {
  if (allowBlank && normalizeText_(value) === "") return null;
  var amount = typeof value === "number" ? value : Number(normalizeText_(value));
  if (!Number.isSafeInteger(amount) || amount <= 0 || amount > 1000000) {
    fail_("PRODUCT_CONFIG_ERROR", "Products 第 " + rowNumber + " 列 " + field + " 必須是 1 到 1000000 的整數");
  }
  return amount;
}

function activeValue_(value) {
  if (value === true || value === 1) return true;
  var normalized = normalizeText_(value).toLowerCase();
  return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "是";
}

function requiredText_(source, field, minLength, maxLength) {
  if (!source || typeof source[field] !== "string") {
    fail_("VALIDATION_ERROR", field + " 必須是文字");
  }
  var value = source[field].trim();
  if (value.length < minLength || value.length > maxLength) {
    fail_("VALIDATION_ERROR", field + " 長度必須介於 " + minLength + " 到 " + maxLength);
  }
  return value;
}

function validateSubmissionId_(value) {
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{7,99}$/.test(value)) {
    fail_("INVALID_SUBMISSION_ID", "submissionId 格式錯誤");
  }
  return value;
}

function validateGender_(value) {
  if (ALLOWED_GENDERS_.indexOf(value) === -1) {
    fail_("INVALID_GENDER", "gender 必須是女、男、其他或不透露");
  }
  return value;
}

function validatePhone_(value) {
  var digits = value.replace(/\D/g, "");
  if (!/^[0-9+()# .-]+$/.test(value) || digits.length < 8 || digits.length > 15) {
    fail_("INVALID_PHONE", "phone 格式錯誤");
  }
  return value;
}

function validateEmail_(value) {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    fail_("INVALID_EMAIL", "email 格式錯誤");
  }
  return value.toLowerCase();
}

function getTestSpreadsheet_() {
  var spreadsheetId = normalizeText_(PropertiesService.getScriptProperties().getProperty(TEST_SPREADSHEET_PROPERTY_));
  if (!spreadsheetId) {
    fail_("CONFIG_ERROR", "尚未設定測試試算表");
  }
  try {
    return SpreadsheetApp.openById(spreadsheetId);
  } catch (error) {
    fail_("CONFIG_ERROR", "無法開啟測試試算表");
  }
}

function requireSheet_(spreadsheet, sheetName) {
  var sheet = spreadsheet.getSheetByName(sheetName);
  if (!sheet) fail_("SCHEMA_ERROR", "缺少 " + sheetName + " 工作表");

  var expected = HEADERS_[sheetName];
  var actual = sheet.getRange(1, 1, 1, expected.length).getDisplayValues()[0].map(normalizeText_);
  if (sheet.getLastColumn() !== expected.length || JSON.stringify(actual) !== JSON.stringify(expected)) {
    fail_("SCHEMA_ERROR", sheetName + " 表頭不符合契約");
  }
  return sheet;
}

function findBySubmissionId_(sheet, submissionId) {
  var lastRow = sheet.getLastRow();
  if (lastRow < 2) return null;
  var values = sheet.getRange(2, 2, lastRow - 1, 1).getDisplayValues();
  for (var index = 0; index < values.length; index += 1) {
    if (normalizeText_(values[index][0]) === submissionId) {
      return sheet.getRange(index + 2, 1, 1, sheet.getLastColumn()).getValues()[0];
    }
  }
  return null;
}

function parseStoredOrderItems_(rawValue) {
  var parsed;
  try {
    parsed = JSON.parse(String(rawValue || "[]"));
  } catch (error) {
    return [];
  }
  if (!Array.isArray(parsed) || parsed.length > 50) return [];

  var safeItems = [];
  for (var index = 0; index < parsed.length; index += 1) {
    var item = parsed[index];
    if (!item || typeof item !== "object" || Array.isArray(item)) return [];
    var productId = normalizeText_(item.productId);
    var name = normalizeText_(item.name);
    var image = normalizeText_(item.image);
    var price = Number(item.price);
    var salePrice = item.salePrice === null || normalizeText_(item.salePrice) === "" ? null : Number(item.salePrice);
    var unitPrice = Number(item.unitPrice);
    var qty = Number(item.qty);
    var lineTotal = Number(item.lineTotal);
    var salePriceValid = salePrice === null || (Number.isSafeInteger(salePrice) && salePrice > 0 && salePrice < price);
    if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(productId)
      || !name
      || !Number.isSafeInteger(price) || price <= 0
      || !salePriceValid
      || !Number.isSafeInteger(unitPrice) || unitPrice <= 0
      || !Number.isInteger(qty) || qty < 1 || qty > 20
      || !Number.isSafeInteger(lineTotal) || lineTotal !== unitPrice * qty) {
      return [];
    }
    safeItems.push({
      productId: productId,
      name: name,
      image: image,
      price: price,
      salePrice: salePrice,
      unitPrice: unitPrice,
      qty: qty,
      lineTotal: lineTotal
    });
  }
  return safeItems;
}

function makeNumber_(prefix, date) {
  var day = Utilities.formatDate(date, TIME_ZONE_, "yyyyMMdd");
  var random = Utilities.getUuid().replace(/-/g, "").slice(0, 10).toUpperCase();
  return prefix + "-" + day + "-" + random;
}

function safeSheetText_(value) {
  var text = String(value);
  return /^[=+-@]/.test(text) ? "'" + text : text;
}

function normalizeText_(value) {
  return value === null || value === undefined ? "" : String(value).trim();
}

function fail_(code, message) {
  var error = new Error(message);
  error.apiCode = code;
  throw error;
}

function errorResponse_(error) {
  console.error(error && error.stack ? error.stack : error);
  var known = error && error.apiCode;
  return jsonResponse_({
    ok: false,
    message: known ? error.message : "伺服器處理失敗",
    error: known ? error.apiCode : "INTERNAL_ERROR"
  });
}

function jsonResponse_(payload) {
  return ContentService.createTextOutput(JSON.stringify(payload))
    .setMimeType(ContentService.MimeType.JSON);
}

function setupTestSheets() {
  var spreadsheet = getTestSpreadsheet_();
  Object.keys(HEADERS_).forEach(function (sheetName) {
    var sheet = spreadsheet.getSheetByName(sheetName);
    if (!sheet) sheet = spreadsheet.insertSheet(sheetName);
    if (sheet.getLastRow() === 0) {
      sheet.getRange(1, 1, 1, HEADERS_[sheetName].length).setValues([HEADERS_[sheetName]]);
      sheet.setFrozenRows(1);
    }
    requireSheet_(spreadsheet, sheetName);
  });
  return "測試工作表已建立並通過表頭驗證";
}
apps-script/SETUP.mdGAS 測試後端:展示商品 GET、訂單/聯絡 POST、Sheet schema、權威計價與去重。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

# GAS 測試環境設定

這份後端只供第 23–28 章運動器材電商練習使用。請建立全新的測試 Spreadsheet 與測試 Web App deployment;不得連接公司、客戶或正式環境資料。

## 1. 建立測試 Spreadsheet

建立空白 Google Spreadsheet,記下 ID,但不要把 ID 寫進 Git、HTML、JavaScript 或這份文件。

在 Apps Script 專案的「專案設定 → 指令碼屬性」新增:

- 屬性:`TEST_SPREADSHEET_ID`
- 值:測試 Spreadsheet ID

後端只讀這個 Script Property,不會使用 active spreadsheet 或前端傳入的 ID。

## 2. 建立 Apps Script 專案

1. 建立獨立的測試 Apps Script 專案。
2. 將 `apps-script/Code.gs` 貼入 `Code.gs`。
3. 將 `apps-script/appsscript.json` 貼入 manifest。
4. 在編輯器手動執行一次 `setupTestSheets()` 並完成授權。

函式只會建立缺少的測試工作表與第一列表頭,不會清除既有資料。若已存在的表頭錯誤,會停止並回報 `SCHEMA_ERROR`。

## 3. 三張表的固定 schema

### Products

| productId | name | image | price | salePrice | active |
| --- | --- | --- | ---: | ---: | --- |
| EQ-MAT-02 | 止滑訓練瑜珈墊 | assets/product-mat.svg | 1280 | 980 | TRUE |
| EQ-BIKE-05 | 靜音磁控健身車 | assets/product-bike.svg | 12800 | 10900 | TRUE |
| EQ-INACTIVE-99 | 停售測試商品 | assets/product-mat.svg | 500 |  | FALSE |

- `productId` 必須唯一。
- `price` 是 1–1,000,000 的整數。
- `salePrice` 可空白;有值時必須是正整數且低於 `price`。
- 只有 `active` 為 TRUE/1/yes/是的商品會由 `listProducts` 公開。
- `image` 只填寫本專案已有的 `assets/product-*.svg` 相對路徑,不使用外部或不存在的圖片網址。

### Orders

`orderNo, submissionId, timestamp, buyer, gender, phone, address, email, subtotal, shipping, total, status, itemsJson`

### Contacts

`contactNo, submissionId, timestamp, name, gender, phone, email, message, status`

表頭文字與順序必須完全一致。Orders 與 Contacts 不需要預填測試資料。

## 4. 測試部署

建立 Web App 的測試 deployment。執行身分與存取範圍只開到完成本課測試所需的最小權限;不要重用正式 deployment。將測試網址只填入本機測試設定,不要提交真實 endpoint。

所有 POST 都使用 `application/x-www-form-urlencoded`:

- `action=createOrder` 或 `action=createContact`
- `payload=<JSON 字串>`

後端只從 `e.parameter` 讀取這兩個欄位,response 固定為 `{ ok, data?, message, error? }`。

## 5. 驗證清單

以下命令中的 `<TEST_WEB_APP_URL>` 必須替換為測試 deployment URL;`-L` 用來跟隨 ContentService redirect。

### 只公開 active 商品

~~~bash
curl -L '<TEST_WEB_APP_URL>?action=listProducts'
~~~

確認回應不含 `EQ-INACTIVE-99`,也不含任何 Sheet ID、內部狀態或其他工作表資料。

### 建立訂單

~~~bash
curl -L -X POST '<TEST_WEB_APP_URL>' \
  --data-urlencode 'action=createOrder' \
  --data-urlencode 'payload={"submissionId":"order-test-0001","buyer":"測試學員","gender":"不透露","phone":"0912-345-678","address":"台北市測試區測試路 1 號","email":"student@example.com","items":[{"productId":"EQ-MAT-02","qty":2}],"subtotal":1,"shipping":0,"total":1,"status":"已付款"}'
~~~

確認:

1. 前端傳入的 subtotal、shipping、total、status 都被忽略。
2. 後端從 Products 重新取得商品與價格。
3. 小計由後端計算;滿 NT$2,000 免運,否則運費 NT$120。
4. 初始狀態固定為「待確認」。
5. Orders 只新增一列,itemsJson 是伺服器商品快照。

使用完全相同的 `submissionId` 重送兩次,兩次應回同一個 orderNo,第二次 `duplicate=true`,Orders 仍只有一列。再用兩個終端機同時送出相同請求,確認 LockService 下仍只寫入一次。

將商品改成 `EQ-INACTIVE-99`、不存在的 ID、qty=0、qty=1.5 或 qty=21,逐項確認後端拒絕;不得只看前端按鈕是否 disabled。

### 建立聯絡訊息

~~~bash
curl -L -X POST '<TEST_WEB_APP_URL>' \
  --data-urlencode 'action=createContact' \
  --data-urlencode 'payload={"submissionId":"contact-test-0001","name":"測試學員","gender":"不透露","phone":"0912-345-678","email":"student@example.com","message":"這是一筆測試聯絡訊息","website":""}'
~~~

確認初始狀態固定為「未處理」。相同 submissionId 重送時 Contacts 仍只有一列;將 website 填入任意文字時應回 `HONEYPOT` 且不得寫入。

## 6. 安全與範圍邊界

- 不信任前端金額、狀態、商品名稱、商品圖片或商品價格。
- 不在前端或 Git 內存放 Spreadsheet ID、deployment URL 或任何 secret。
- 僅使用測試 Sheet、假姓名、假電話、假地址與測試信箱。
- 本練習沒有付款、會員登入、庫存扣減、退款或正式訂單流程。
- 完成驗證後可撤銷測試 deployment;不要把測試 deployment 改成正式服務。
apps-script/appsscript.jsonGAS 測試後端:展示商品 GET、訂單/聯絡 POST、Sheet schema、權威計價與去重。

這段要貼在哪裡:先在自己的工作資料夾建立同名相對位置,再複製內容。

{
  "timeZone": "Asia/Taipei",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

完成驗收

下一步

Starter 完成後,不要直接看 Solution;先前往 Checkpoint,自己找出最後一個核心缺口。

前往 Checkpoint,自己補最後一個核心缺口