商用網站架設實務

LESSON-25 · solution

完成商品目錄、購物車、checkout 與聯絡表單的離線 mock

這一頁不是直接複製答案;先逐檔看差異、修改原因與驗收證據,再把需要的改動帶回自己的資料夾。 這頁用 可觀察證據 和 anchor 指出下一個動作。

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

開始前確認

這一頁的結果:Solution 的服務頁正好三張由 services 陣列產生的卡片;關於頁只有一組背景/工作方式/信任內容,聯絡頁只有一個含三欄的 form;有效送出不重新載入、空白與錯誤 Email 有證據,且 390px/Tab 可操作

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

會用到的檔案:assets/logo-mark.svg、assets/product-band.svg、assets/product-bench.svg、assets/product-bike.svg、assets/product-dumbbell.svg、assets/product-kettlebell.svg、assets/product-mat.svg、cart.html、checkout.html、contact.html、css/style.css、index.html、js/api.js、js/cart.js、js/checkout.js、js/common.js、js/config.js、js/contact.js、js/home.js、js/order-success.js、js/products.js、js/store.js、order-success.html、products.html

本堂焦點:完成商品目錄、購物車、checkout 與聯絡表單的離線 mock

照這個順序做

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

差異 1

先用離線資料證明商品 UI、欄位與搜尋契約,不把網路問題混入本章。

目的

先用離線資料證明商品 UI、欄位與搜尋契約,不把網路問題混入本章。

修改檔案

js/api.js、js/products.js、products.html

程式碼位置

offline product catalog

現在要做:先對照差異,再檢查 在 js/api.js、js/products.js、products.html 的「offline product catalog」對照這項變更:const matched = products.filter((product) => product.name.includes(keyword) || product.productId.includes(keyword));

貼上位置:js/api.js、js/products.js、products.html 的 offline product catalog 區塊

const matched = products.filter((product) => product.name.includes(keyword) || product.productId.includes(keyword));

預期結果:只渲染 active 商品;有特價時保留原價與特價語意;輸入名稱或商品編號可篩選,沒有其他搜尋 key。

驗收證據:商品卡 DOM、四個顯示欄位、名稱/編號搜尋結果、img src/alt 與 Network 零請求。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

差異 2

先把選購與金額顯示做成可重現本地流程;L27 才由 GAS 權威重算。

目的

先把選購與金額顯示做成可重現本地流程;L27 才由 GAS 權威重算。

修改檔案

js/store.js、js/cart.js、js/checkout.js、cart.html、checkout.html

程式碼位置

cart and checkout mock

現在要做:先對照差異,再檢查 在 js/store.js、js/cart.js、js/checkout.js、cart.html、checkout.html 的「cart and checkout mock」對照這項變更:const cart = window.EcommerceStore.getCart(); const summary = window.EcommerceApi.buildCartSummary(cart);

貼上位置:js/store.js、js/cart.js、js/checkout.js、cart.html、checkout.html 的 cart and checkout mock 區塊

const cart = window.EcommerceStore.getCart();
const summary = window.EcommerceApi.buildCartSummary(cart);

預期結果:重新整理仍保留購物車;checkout 有購買人、性別、電話、地址、信箱;顯示商品總額、運費、訂單總額與 mock 狀態。

驗收證據:localStorage 快照、購物車數量、三個金額、FormData key 與 order-success mock 畫面。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

差異 3

先驗證聯絡資料 contract 與狀態畫面,下一章才替換 transport。

目的

先驗證聯絡資料 contract 與狀態畫面,下一章才替換 transport。

修改檔案

contact.html、js/contact.js

程式碼位置

offline contact mock and network guard

現在要做:先對照差異,再檢查 在 contact.html、js/contact.js 的「offline contact mock and network guard」對照這項變更:const contactPayload = { name:data.get('name'), gender:data.get('gender'), phone:data.get('phone'), email:data.get('email'), message:data.get('message') };

貼上位置:contact.html、js/contact.js 的 offline contact mock and network guard 區塊

const contactPayload = { name:data.get('name'), gender:data.get('gender'), phone:data.get('phone'), email:data.get('email'), message:data.get('message') };

預期結果:五個聯絡欄位都有 label/id/name/required;送出不離頁,可重現 idle/loading/success/error,Network 無資料 request。

驗收證據:Elements、FormData 五個 key、aria-live/data-state、Network 與全專案 fetch 搜尋。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

差異 4

Checkpoint 只修正特價商品的單一計價缺口。

目的

Checkpoint 只修正特價商品的單一計價缺口。

修改檔案

js/api.js

程式碼位置

effectivePrice

現在要做:先對照差異,再檢查 在 js/api.js 的「effectivePrice」對照這項變更:const effectivePrice = product.salePrice ?? product.price;

貼上位置:js/api.js 的 effectivePrice 區塊

const effectivePrice = product.salePrice ?? product.price;

預期結果:FIT-001 ×2 使用 1380 而非 1680,小計與訂單總額同步下降。

驗收證據:手算結果、購物車列單價、subtotal 與 total。

卡住時先看這裡

常見錯誤:只複製完整答案,卻沒有先理解這一項差異的責任。

第一個檢查:只對照目前差異指定的檔案與 anchor。

階段檔案

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

開啟 solution 的可執行入口

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>正在連線 後端,整理購物車商品…</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">這是前端估算。後端 會依商品資料重新計算,結帳成功頁只顯示後端回應的總額與狀態。</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>正在連線 後端,準備結帳資料…</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>這裡只做使用者介面估算。建立訂單後,商品總額、運費、訂單總額及狀態全部以 後端 回應覆蓋。</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>正式產物預設連線到公開 後端 Web App;在 localhost 開啟時會自動切換成 local mock,避免課堂練習誤寫入 外部資料表。</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>正在連線 後端,讀取精選商品…</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 products=[
{productId:"EQ-DUMBBELL-01",name:"可調式六角啞鈴組",image:"assets/product-dumbbell.svg",price:2680,salePrice:2280,active:true},
{productId:"EQ-MAT-02",name:"止滑訓練瑜珈墊",image:"assets/product-mat.svg",price:1280,salePrice:980,active:true},
{productId:"EQ-KETTLE-03",name:"鑄鐵競技壺鈴",image:"assets/product-kettlebell.svg",price:1880,salePrice:1580,active:true},
{productId:"EQ-BAND-04",name:"五段阻力帶套組",image:"assets/product-band.svg",price:780,salePrice:650,active:true},
{productId:"EQ-BIKE-05",name:"靜音磁控健身車",image:"assets/product-bike.svg",price:12800,salePrice:10900,active:true},
{productId:"EQ-BENCH-06",name:"多角度訓練椅",image:"assets/product-bench.svg",price:5680,salePrice:4980,active:true}
];
var wait=function(){return new Promise(function(resolve){setTimeout(resolve,180);});};
var price=function(product){return product.salePrice>0&&product.salePrice<product.price?product.salePrice:product.price;};
var shipping=function(subtotal){return subtotal>0&&subtotal<2000?120:0;};
async function getProducts(){await wait();return{ok:true,mode:"local-mock",data:{products:products.map(function(item){return Object.assign({},item);})}};}
async function createOrder(payload){await wait();var byId=new Map(products.map(function(item){return[item.productId,item];}));var items=payload.items.map(function(input){var product=byId.get(input.productId);if(!product||!product.active)throw new Error("商品不存在或已下架");var qty=Math.max(1,Math.floor(Number(input.qty)||1));var unitPrice=price(product);return{productId:product.productId,name:product.name,image:product.image,price:product.price,salePrice:product.salePrice,unitPrice:unitPrice,qty:qty,lineTotal:unitPrice*qty};});var subtotal=items.reduce(function(sum,item){return sum+item.lineTotal;},0);var fee=shipping(subtotal);return{ok:true,mode:"local-mock",message:"本機模擬訂單完成,沒有傳送到外部服務。",data:{duplicate:false,orderNo:"MOCK-"+String(Date.now()).slice(-8),items:items,subtotal:subtotal,shipping:fee,total:subtotal+fee,status:"教學模擬"}};}
async function sendContact(){await wait();return{ok:true,mode:"local-mock",message:"本機模擬聯絡完成,沒有傳送到外部服務。",data:{duplicate:false,contactNo:"MOCK-CONTACT-"+String(Date.now()).slice(-8),status:"教學模擬"}};}
window.EcommerceApi=Object.freeze({getProducts:getProducts,createOrder:createOrder,sendContact:sendContact,activePrice:price,shippingFor:shipping,isMockMode:function(){return true;}});
})();
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 或寫入 外部資料表。"
      : mode === "live-test"
        ? "本機測試連線模式:資料會傳到注入的 後端 測試 endpoint,請勿使用正式個資。"
        : "已連線至公開 後端 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(){window.ECOMMERCE_CONFIG=Object.freeze({MODE:"local-mock",SHIPPING_FEE:120,FREE_SHIPPING_THRESHOLD:2000});})();
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 或寫入 外部資料表。"
    : receipt.responseMode === "live-test"
      ? "以上金額與狀態已使用 後端 測試 endpoint 回應覆蓋前端估算。"
      : "以上金額與狀態已使用公開 後端 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>正在連線 後端,讀取商品資料…</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>

完成驗收

下一步

Solution 對照完成後,回到 README 整理證據,並準備下一堂課的先備知識。

回到 README,整理本堂證據並銜接下一堂

繼續自主學習

開啟 examples/supplement/index.html;自學檔案不混入 Solution files。