# บทเรียน 07 — GSAP product story, storefront และ quote demo

## ผลลัพธ์การเรียนรู้

ผู้เรียนจะออกแบบ storyboard แนะนำสินค้า HUSH ONE, ตรวจ desktop pin เทียบกับ natural flow/reduced motion, ทดลอง listening scenarios แบบ visual-only, โหลดสินค้า `GET /api/catalog` และส่ง cart ด้วย `POST /api/quotes`. Worker เป็นผู้คำนวณ `totalPrice` แบบ integer satang จาก server catalog ของ demo; result คือ `quote-demo` ไม่ใช่ card checkout หรือคำเสนอทางการค้า.

## ต้องมีก่อนเริ่ม

- รัน `npm run db:local` แล้วคง `npm run dev` ไว้ที่ `http://localhost:3320`
- project นี้ติดตั้ง `gsap` แล้ว (ตรวจได้ด้วย `npm ls gsap`); อย่าเพิ่ม animation package สำหรับ lab
- ใช้ชื่อ/อีเมลสมมติเท่านั้น. `DEMO_MODE=true` ทำให้ D1 redacts name/email และข้าม Turnstile เพื่อสอน local flow; มันไม่ได้เปิดใช้รับข้อมูลลูกค้าจริง

## จากหน้าโชว์สินค้า สู่ product story

เว็บสินค้าแบบพรีเมียมไม่ได้รีบวาง catalog grid ไว้ใต้ hero แต่ค่อยตอบคำถามของผู้ซื้อทีละบท: สินค้านี้คืออะไร ให้ประสบการณ์แบบไหน ใช้สบายหรือไม่ ใช้ได้นานเท่าไร แล้วจึงพาไปเปรียบเทียบและเลือกซื้อ หน้า AirPods Max ของ Apple ใช้ลำดับจากเสียง ชิ้นส่วน โหมดการฟัง ไปจนถึงการใช้งานและข้อมูลก่อนซื้อ เรานำ **หลักการจัดจังหวะเนื้อหา** นี้มาเรียน โดยสร้างชื่อ ข้อความ สี ภาพและ interaction ของ HUSH เอง ไม่คัดลอกหน้าหรือทรัพย์สินของ Apple ([Apple product page](https://www.apple.com/airpods-max/), [Apple Newsroom, 8 ธันวาคม 2020](https://www.apple.com/newsroom/2020/12/apple-introduces-airpods-max-the-magic-of-airpods-in-a-stunning-over-ear-design/)).

Storyboard ของตัวอย่างจึงยาวพอให้เห็นหน้าที่ของแต่ละ section:

| ช่วง | คำถามของผู้ชม | สิ่งที่หน้าใช้ตอบ |
| --- | --- | --- |
| Hero + highlights | นี่คืออะไร และเด่นเรื่องใด | HUSH ONE, ภาพสินค้าขนาดใหญ่, จุดเด่น 3 เรื่อง |
| Sound stage | แนวคิดเรื่องเสียงเป็นอย่างไร | วงแหวน driver, copy หนึ่งประเด็น และ scenario controls แบบ visual-only |
| Materials + everyday | ใส่สบายและอยู่กับวันจริงอย่างไร | close-up, วัสดุสมมติ, แบตเตอรี่สมมติ 30 ชั่วโมง และ USB-C |
| Specs + compare | ต่างจากรุ่นอื่นอย่างไร | รายละเอียดแบบเปิดอ่านได้และข้อมูลสินค้า 3 รุ่นจาก API |
| Shop + FAQ + CTA | ขั้นต่อไปคืออะไร | cart, quote-demo, คำตอบเรื่องข้อมูลสมมติ และทางกลับไปเลือกรุ่น |

HUSH ONE คือชื่อที่ใช้เล่าเรื่องให้ catalog item `headphones` หรือ “หูฟัง Studio One” เท่านั้น ส่วนราคาและสินค้าทั้งสามรุ่นยังยึด `GET /api/catalog`. ค่า 40 mm, 30 ชั่วโมง และสเปกอื่นเป็น **ข้อมูลสมมติสำหรับการเรียน** ไม่ใช่ผลทดสอบผลิตภัณฑ์จริง ช่วง listening scenarios เปลี่ยนเฉพาะภาพวงแหวน แท่ง visualizer และข้อความ ไม่มีการเล่นเสียงหรือจำลองผลตัดเสียงจริง

ช่วง sound stage ใช้ pin เพื่อให้ผู้ชมโฟกัสกับเหตุการณ์เดียว แต่ทำเฉพาะจอที่กว้างอย่างน้อย 1024px สูงอย่างน้อย 700px และไม่ได้ขอ Reduce Motion. GSAP แนะนำให้ pin โครงที่นิ่งแล้ว animate children เพราะการ transform pinned element เองทำให้ค่าที่คำนวณไว้คลาดเคลื่อนได้ ([ScrollTrigger](https://gsap.com/docs/v3/Plugins/ScrollTrigger/)). มือถือ จอเตี้ย และ reduced motion อ่านเนื้อหาเดียวกันแบบ natural document flow; `gsap.matchMedia()` จะ revert branch เมื่อ media condition เปลี่ยน ([GSAP `matchMedia()`](https://gsap.com/docs/v3/GSAP/gsap.matchMedia%28%29/)).

แบบฝึก product story ฉบับลึกนี้เป็น **กิจกรรมต่อยอดสำหรับศึกษาด้วยตนเอง** เนื้อหาไม่เพิ่มเวลาในตารางอบรม 2 วัน วันละ 4 ชั่วโมง

## Prompt สำหรับสร้างเว็บสินค้า GSAP

ใช้ prompt นี้ในสำเนา starter เพื่อสร้างเว็บสินค้า “HUSH” ก่อนเริ่ม lab. สำเนา canonical อยู่ที่ [Prompt ฉบับเต็ม — HUSH product story](../prompts/hush-product-story-prompt.md) เว็บไซต์ตัวอย่างและภาพด้านล่างเป็นแนวทางให้เทียบ ส่วน motion, keyboard และราคา server-side ต้องตรวจขณะใช้งานจริงด้วย

```text
ปรับเว็บไซต์โชว์สินค้าเครื่องเสียงภาษาไทยแบรนด์สมมติ “HUSH” ใน starter หลักสูตร AI Web Studio
ให้เป็น product story ที่มีเนื้อหาและจังหวะใกล้เคียงคุณภาพของเว็บไซต์เปิดตัวผลิตภัณฑ์ระดับพรีเมียม
ใช้ Apple เป็น reference เฉพาะหลักการจัดลำดับเรื่อง พื้นที่ว่าง ภาพสินค้าขนาดใหญ่ และการเปิดเผยรายละเอียดทีละบท
ห้ามคัดลอกข้อความ ภาพ screenshot โลโก้ ชื่อผลิตภัณฑ์ สีประจำรุ่น layout แบบจุดต่อจุด หรือ trade dress ของ Apple
เขียนชื่อ ประโยชน์ รายละเอียด ภาพประกอบ SVG/CSS และ visual interaction ของ HUSH ขึ้นใหม่

ก่อนแก้ ให้อ่าน package.json, examples/store/{index.html,style.css,main.js},
migrations/0001_initial.sql,
GET /api/catalog และ POST /api/quotes ใน src/worker.ts
สรุป storyboard, state ของ interaction, motion plan และไฟล์ที่จะเปลี่ยนก่อนลงมือ
แก้เฉพาะ examples/store/{index.html,style.css,main.js}
ใช้ semantic HTML, CSS, vanilla JavaScript, GSAP และ ScrollTrigger ที่ติดตั้งอยู่แล้ว
ไม่เพิ่ม dependency ไม่แก้ Worker, D1, migration, API contract, ข้อมูล seed หรือเว็บตัวอย่างอื่น

เป้าหมายของหน้า:
- ผู้ชมทั่วไปเข้าใจ HUSH ONE, เปรียบเทียบสินค้าสามรุ่น และขอใบเสนอราคาสาธิตได้
- “HUSH ONE” เป็นชื่อเชิง editorial ของ catalog item id `headphones` ซึ่ง API เรียกว่า “หูฟัง Studio One”
- รักษา catalog IDs เดิมทั้งสามค่า: `headphones`, `speaker`, `earbuds`
- ชื่อ คำอธิบาย และราคาในการ์ดร้าน/ตารางเปรียบเทียบต้องมาจาก GET /api/catalog
- สเปก 40 mm, แบตเตอรี่ 30 ชั่วโมง และรายละเอียดเชิงผลิตภัณฑ์อื่นเป็นข้อมูลสมมติ
  ต้องมีป้าย “ข้อมูลสมมติสำหรับการเรียน” ใกล้เนื้อหา ห้ามเขียนเป็นผลทดสอบหรือคำรับรองจริง

ทิศทางภาพ “product-led studio editorial”:
- ใช้ palette Cloud #f5f5f7, Ink #22202b, Lilac #dcd5ff,
  Iris #6551b7, Muted #686471 และ White #ffffff เป็น design tokens
- ใช้ IBM Plex Sans Thai สำหรับเนื้อหา/หัวเรื่องไทย และ Inter Bold สำหรับ wordmark HUSH ONE
  กับตัวเลขเชิงเทคนิค โดยใช้เฉพาะฟอนต์ local ใน public/fonts
- ใช้ภาพเดิม `/images/headphones.webp` เป็นภาพสินค้าหลัก กำหนด width/height หรือ aspect-ratio
  เพื่อกัน layout shift; ใช้ CSS crop, background, shadow และ framing สร้างมุมมองใหม่ได้
- ไม่สร้างหรืออ้างว่าได้สร้างภาพใหม่ และไม่ดึงภาพ third-party
- ใช้พื้นที่ขาวและ lilac กว้าง สลับกับบทเสียงพื้นเข้มเพียงหนึ่งช่วง
- ใช้ border radius, shadow และ gradient เท่าที่สื่อวัสดุหรือ depth ของสินค้า ไม่ตกแต่งทุก section

สร้าง storyboard ตามลำดับนี้ โดยแต่ละบทมีหนึ่งประโยชน์หลักและหนึ่งหลักฐาน/คำอธิบาย:
1. sticky product navigation: wordmark HUSH, anchor ไปภาพรวม/เสียง/สเปก/เลือกรุ่น,
   ปุ่มเปิดกระเป๋าเดิม และ anchor offset ที่ไม่บัง heading
2. hero: eyebrow “HUSH ONE / OVER-EAR HEADPHONES”, h1 เดิม “ฟังโลก ในแบบของคุณ”,
   คำอธิบายสั้น ราคา HUSH ONE ที่อัปเดตจาก API; ระหว่าง loading/error ให้แสดง
   “ดูรุ่นและราคาที่พร้อมให้ทดลองด้านล่าง” โดยไม่แต่งตัวเลขราคา,
   CTA “สำรวจรุ่นและราคา” และ “รู้จัก HUSH ONE” พร้อมภาพหูฟังขนาดใหญ่
3. editorial highlights: การ์ดสั้น 3 เรื่อง ได้แก่ เวทีเสียง ความสบาย และจังหวะใช้งานทั้งวัน
   หลีกเลี่ยงตัวเลข/คำอ้างประสิทธิภาพที่ไม่มีที่มา
4. sound chapter พื้นเข้ม: เวทีวงแหวน driver เป็น signature ของหน้า
   มี HUSH ONE, “40 mm · ข้อมูลสมมติ” และ copy อธิบายเสียงแบบไม่อ้างผลวัดจริง
5. listening scenarios: ปุ่ม “โฟกัสงาน”, “ระหว่างทาง”, “พักสักครู่” เปลี่ยนเฉพาะข้อความ
   และความสูงของแท่ง visual equalizer; ใช้ button กับ `aria-pressed`, keyboard ได้,
   มี status text และข้อความชัดเจนว่าเป็นภาพสาธิต ไม่มีเสียงเล่นและไม่จำลองผลตัดเสียงจริง
6. materials/comfort: ภาพ close-up จาก asset เดิมหรือ CSS composition พร้อมอธิบายวัสดุสมมติ
   โดยไม่เลียนแบบชื่อวัสดุหรือชิ้นส่วนของผลิตภัณฑ์จริง
7. everyday/battery: เล่าแบตเตอรี่สมมติ 30 ชั่วโมงและ USB-C เป็นสถานการณ์ใช้งานตัวอย่าง
   ไม่ใส่ข้อความรับประกันหรือ claim ที่ไม่ได้ทดสอบ
8. specifications: ใช้ `<details>` หรือโครง semantic ที่อ่านได้โดยไม่ใช้ JavaScript
   แสดง 40 mm, 30 ชั่วโมง, USB-C และข้อมูลสมมติอื่นอย่างกระชับ
9. API-backed comparison: วางต่อจากการ์ด shop ใน section เดียวกัน เปรียบเทียบสินค้าสาม
   catalog IDs จาก response เดียวกับการ์ด แสดงชื่อ คำอธิบาย และราคาโดยใช้ API
   พร้อม loading, empty และ error state; ห้าม hard-code ราคาเป็น fallback
10. shop: รักษาการ์ดสินค้า ปุ่มเพิ่มสินค้า กระเป๋า quantity controls และ quote form เดิม
11. FAQ: ใช้ native `<details>` อธิบายข้อมูลสมมติ, quote-demo และไม่มี audio playback
12. closing CTA: พากลับไปเลือกรุ่นหรือเปิดส่วนสินค้า โดยไม่อ้างว่าเป็น checkout

ข้อกำหนด GSAP และ responsive:
- register ScrollTrigger หนึ่งครั้ง และคง `gsap.matchMedia()` ที่ active เพียงหนึ่ง instance;
  ใช้ตัวแปร `let motion` กับ `setupMotion()` เพื่อ revert ก่อนสร้างใหม่เมื่อคืนหน้าจาก bfcache
- เขียน HTML/CSS ให้เนื้อหาทุกบทมองเห็นและอ่านตามลำดับได้ตั้งแต่ก่อน JavaScript โหลด
  ห้ามตั้ง opacity:0/visibility:hidden เป็น initial state ของเนื้อหาสำคัญ
- เปิด pinned sound stage เฉพาะ media query
  `(min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference)`
- pin wrapper `.story-stage` หรือ section ที่นิ่ง แล้ว animate เฉพาะ children เช่นวงแหวน,
  product render, copy และ visualizer; ห้าม animate transform ของ pinned element เอง
- ใช้ scrub แบบมีขอบเขต ไม่ scroll-jack ไม่เปลี่ยน native scrollbar และไม่ผูก CTA กับจุดจบ animation
- ระหว่าง 761–1023px, desktop ที่สูงไม่ถึง 700px, มือถือ และ reduced motion
  ให้ทุก section เรียง natural document flow โดยไม่มี pin และไม่มี scrub
- motion ย่อยที่ไม่จำเป็นทำงานเฉพาะ `prefers-reduced-motion: no-preference`
- ใน reduced motion ให้ clear transform/opacity/visibility ที่ animation อาจทิ้งไว้
  และให้ scenario controls, anchors, cart และ quote form ทำงานครบ
- เมื่อ media query เปลี่ยน GSAP ต้อง revert animation/ScrollTrigger ของ branch เดิม
- เรียก `motion.revert()` เมื่อ page ถูกทิ้งผ่าน `pagehide`; ถ้า `pageshow.persisted` คืนหน้าจาก bfcache
  ให้สร้าง motion ใหม่อย่างปลอดภัยหรือ refresh โดยไม่เพิ่ม trigger ซ้ำ
- refresh ScrollTrigger หลัง image/font/DOM geometry พร้อมจริงเท่านั้น ไม่เรียกทุก frame

ข้อกำหนด interaction และ accessibility:
- มี skip link, landmark, heading hierarchy, alt ที่อธิบายภาพ, visible focus และสี contrast อ่านได้
- ทุก control มีชื่อที่ screen reader เข้าใจ; scenario buttons มี selected state ทั้งภาพและ `aria-pressed`
- scenario เปลี่ยน state แบบ visual-only และต้องไม่เรียก Web Audio, `<audio>`, autoplay หรือขอสิทธิ์ microphone
- touch target ของ control หลักอย่างน้อย 44px; หน้า 320px และ 390px ต้องไม่ล้นแนวนอน
- sticky navigation ไม่บัง target หลังคลิกลิงก์ และไม่มี content ถูกภาพสินค้าทับ
- ใช้ status region สำหรับ catalog, scenario, cart และ quote โดยไม่ประกาศข้อความซ้ำเกินจำเป็น

รักษา cart และ quote contract เดิม:
- โหลดสินค้าและราคาจาก GET /api/catalog; cart เก็บเฉพาะ productId กับ quantity
- cart เพิ่ม/ลด/ลบสินค้า มี empty state ยอดรวม preview และ quantity 1–10
- เปิด dialog แล้ว focus อยู่ภายใน ปิดด้วย Escape ได้ พื้นหลังไม่รับ Tab
  และปิดแล้วคืน focus ไปปุ่มที่เปิดจริง; render ใหม่ต้องรักษา focus ของ quantity/remove control
- ส่ง JSON `{items:[{productId,quantity}],name,email,consent,website}` ไป POST /api/quotes
- ส่ง `Idempotency-Key`; ใช้ key เดิมเมื่อ retry payload เดิมหลัง network error
  และสร้าง key ใหม่เมื่อ cart หรือ form data เปลี่ยน
- ห้ามส่ง price, unitPrice, totalPrice, stock หรือ status ให้ server เชื่อ
- Worker อ่านราคาจาก D1 และคืน totalPrice เป็น integer satang
- HTTP 201 คือ `quote-demo`: แสดงเลขอ้างอิงและยอดที่ server คำนวณ
  ห้ามใช้คำว่า “ซื้อสำเร็จ”, “ชำระแล้ว” หรือสร้าง card/payment UI
- เมื่อ 400/403/5xx, response ไม่ใช่ JSON หรือ network error ให้แสดงข้อความที่ทำต่อได้
  และเก็บ cart/form ไว้แก้หรือ retry
- ใช้ชื่อ/อีเมลสมมติและแจ้งการปกปิดข้อมูลก่อนเขียน D1 ใน DEMO_MODE

ตรวจรับก่อนส่งงาน:
- ยืนยันว่ามีสาม catalog IDs เดิมและ HUSH ONE แสดงความสัมพันธ์กับ “หูฟัง Studio One” ชัดเจน
- ตรวจ no-JavaScript ว่า hero, chapters, specs, FAQ และทางไป shop ยังอ่านได้
- ตรวจ 1440×900 ว่า sound stage pin ทำงาน และ 1024×699 ว่าไม่ pin
- ตรวจ 390px และ 320px ว่าเป็น natural flow ไม่มี horizontal overflow และ CTA แตะได้
- ตรวจ live media-query change, reduced motion และ back-forward cache ว่า trigger ไม่ซ้ำ/เนื้อหาไม่หาย
- ใช้ keyboard เลือก listening scenarios, เปิด/ปิด cart, แก้ quantity และส่ง form
- ตรวจ catalog loading/success/empty/error, quote success/network error และ error response ที่ไม่ใช่ JSON
- แก้ราคาใน DevTools แล้วพิสูจน์ว่า response quote ยังมาจาก D1 ฝั่ง server
- รันชุด regression tests เดิมของ store, npm run typecheck, npm run test,
  npm run build และ npm run lint บนพอร์ตที่ shared-infra จองไว้

หลังแก้ ส่งสรุป storyboard, design tokens, motion branches, ไฟล์ที่เปลี่ยน,
ผลตรวจที่รันจริง และข้อจำกัดที่ยังเหลือ แยกสิ่งที่ตรวจแล้วออกจากสิ่งที่ยังไม่ได้ตรวจ
```

**ผลลัพธ์ที่ควรได้:** เว็บ [HUSH](/examples/store/) มี product story หลายบท, HUSH ONE ที่สัมพันธ์กับ catalog, sound stage, listening scenarios แบบ visual-only, เปรียบเทียบสามรุ่น, cart และใบเสนอราคาสาธิต ดู [ภาพหน้าเว็บ](/images/results/store-desktop.png) และ [prompt ภาพหูฟังเดิม](../assets/image-provenance.md#headphones) จากนั้นตรวจ motion และ form ตาม lab A/B

### Prompt ติดตามผลหลังสร้าง

```text
ตรวจเว็บ HUSH product story ที่เพิ่งสร้างและแก้เฉพาะปัญหาที่พบ
ที่ 1440×900 ให้ sound stage pin โดย animate เฉพาะ children; ที่ 1024×699, 390px
และ prefers-reduced-motion ให้ทุกบทเรียง natural flow ไม่มี pin และไม่มีเนื้อหาหาย
ใช้ keyboard สลับ โฟกัส/เดินทาง/พักผ่อน แล้วตรวจ aria-pressed กับ status;
ยืนยันว่า interaction เป็นภาพสาธิตเท่านั้นและไม่มี audio playback
เปิด cart จากการ์ดสินค้า ปิดด้วย Escape ต้องคืน focus ไปปุ่มเดิม
เปลี่ยน quantity แล้ว focus ไม่หลุด และพื้นหลังไม่รับ Tab
ทดลอง catalog empty/error, quote success, non-JSON error และ network error;
ราคาสำเร็จต้องมาจาก API และ error ต้องเก็บ cart/form ไว้
รายงานหลักฐาน 1440×900/1024×699/390px, reduced motion, keyboard และ request/response หลังแก้
```

## Contract ของ current demo

![เส้นทางอำนาจด้านราคาของร้าน HUSH โดยหน้า store โหลด catalog จาก API ส่งเฉพาะ productId และ quantity ไป quotes แล้ว Worker อ่านราคาจาก D1 products ก่อนบันทึก quotes กับ quote_items](/diagrams/quote-authority.svg)

[เปิดแผนภาพขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง](/diagrams/quote-authority.html)

ความสัมพันธ์เดิมยังครบ: `/examples/store/` และ `main.js + GSAP` เรียก `GET /api/catalog` และส่ง `POST items + demo contact` ไป `/api/quotes`; ทั้ง catalog และ quote อ่าน `D1 products`; quote ใช้ D1 batch เขียน `quotes + quote_items` หน้าเว็บคำนวณยอด preview ได้ แต่ไม่มีอำนาจกำหนดราคาที่ Worker บันทึก

**ฝึกอ่านภาพ:** สมมติผู้ใช้แก้ยอดใน DevTools เป็น 1 บาท ให้ลากเส้นข้อมูลที่ Worker ใช้คำนวณจริงและบอกว่าหลักฐานใดพิสูจน์ผล

เมื่อ D1 binding พร้อม `GET /api/catalog` คืน `{equipment, products}` จาก D1 seeded catalog. `POST /api/quotes` รับ `items` (1–10 รายการ unique แต่ละรายการ `{productId, quantity}`), `name`, `email`, `consent:true`, `website:""`; Worker validate IDs/quantity, โหลด product price จาก **D1 ฝั่ง server** แล้ว snapshot `unit_price` ลง `quote_items`. ดังนั้น browser preview มีไว้เพื่อ UX เท่านั้น และการแก้ total/price ใน DevTools ไม่เปลี่ยน response. หาก D1 binding ไม่พร้อม API ต้องแจ้งข้อผิดพลาด ไม่เสิร์ฟราคาสำรองที่อาจทำให้เข้าใจผิด.

## Lab A: GSAP ที่ไม่ทำลาย accessibility

1. เปิดหน้า store และตรวจ package/version ที่มีจริง:

   ```bash
   npm run db:local
   npm run dev
   npm ls gsap
   ```

   เปิด `http://localhost:3320/examples/store/`. **ผลที่คาดหวัง:** hero, highlights, sound stage, listening scenarios, materials, everyday use, specs, product comparison, shop และ FAQ เรียงเป็นเรื่องเดียวกัน; product data มาจาก `/api/catalog` และ cart เริ่มว่าง.

2. อ่าน `examples/store/main.js` ก่อนแก้. Current demo register `ScrollTrigger` หนึ่งครั้งและรวม motion branches ไว้ใน `gsap.matchMedia()`. Sound stage ใช้ scrub + pin เฉพาะ `(min-width: 1024px) and (min-height: 700px) and (prefers-reduced-motion: no-preference)`; จอที่ไม่เข้าเงื่อนไขอ่านบทเดียวกันตาม document flow. GSAP `matchMedia()` revert setup เมื่อ media condition เปลี่ยน ([GSAP docs](https://gsap.com/docs/v3/GSAP/gsap.matchMedia%28%29/)).

3. เทียบที่ 1440×900 กับ 1024×699 จากนั้นเปิด OS/browser Reduce Motion แล้ว reload. ตรวจ hero, ทุก story chapter, specs, shop, cart และ CTA ยังมองเห็นและใช้งานได้. `prefers-reduced-motion` สื่อว่าผู้ใช้ขอลด/แทน motion ที่ไม่จำเป็น ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/%40media/prefers-reduced-motion)). **ผลที่คาดหวัง:** จอใหญ่พอ pin เฉพาะ sound stage; จอเตี้ยและ reduced motion ไม่มี scrub/pin และไม่มีเนื้อหาค้าง opacity 0 หรือถูกซ่อน.

4. ใช้ keyboard เลือก “โฟกัสงาน”, “ระหว่างทาง”, “พักสักครู่”. ตรวจ `aria-pressed`, ข้อความ status และภาพ visualizer เปลี่ยนสอดคล้องกัน โดย Network/Media panel ไม่มีไฟล์เสียง, autoplay หรือ microphone request. จากนั้นเปิด cart, กด Escape เพื่อปิด และ Tab ตรวจ close button/form controls. Current demo ย้าย focus ไป close button ตอนเปิด, จำกัด focus ภายใน dialog และทำพื้นหลังเป็น inert; ตอนปิดคืน focus ไปยังปุ่มที่เปิดจริง. ทดลองแก้ quantity ด้วย keyboard และตรวจว่า input เดิมยังรับ focus อยู่ตามกรณีทดสอบใน `tests/browser.spec.mjs`.

5. ถ้าเขียน animation ใหม่ใน component framework ให้ใช้ `gsap.context(() => { /* animations */ }, root)` และ return `ctx.revert()` ใน lifecycle cleanup; context scope selectors และเก็บ animations/ScrollTriggers เพื่อ revert ได้ ([GSAP `context()`](https://gsap.com/docs/v3/GSAP/gsap.context%28%29/)). Custom listener ที่เพิ่มเองต้อง remove ใน cleanup. `ScrollTrigger.refresh(true)` ใช้หลัง image/font/DOM geometry เปลี่ยนจริง ไม่ใช่ทุก frame ([ScrollTrigger refresh](https://gsap.com/docs/v3/Plugins/ScrollTrigger/static.refresh%28%29/)). ส่วนนี้เป็น **pattern สำหรับการขยาย**; current static demo ไม่ได้ใช้ component mount/unmount.

## Lab B: quote API และราคา server-side

1. ดู catalog จริงก่อนด้วย:

   ```bash
   curl -sS http://localhost:3320/api/catalog
   ```

   **ผลที่คาดหวัง:** JSON มี `products` เช่น `headphones` ราคา `459000` (satang) และ `currency` ของ quote เป็น `THB`. อย่าใช้ price ที่ browser render เป็น authority.

2. ส่ง quote demo โดยต้องมี Origin และ idempotency key:

   ```bash
   curl -i -X POST http://localhost:3320/api/quotes \
     -H 'Origin: http://localhost:3320' \
     -H 'Content-Type: application/json' \
     -H 'Idempotency-Key: quote-lab-001' \
     --data '{"items":[{"productId":"headphones","quantity":2}],"name":"Demo Learner","email":"learner@example.test","consent":true,"website":""}'
   ```

   **ผลที่คาดหวัง:** HTTP `201` และ shape นี้ (`id` เปลี่ยนได้):

   ```json
   {"id":"<uuid>","totalPrice":918000,"currency":"THB","status":"quote-demo","message":"สร้างใบเสนอราคาสาธิตแล้ว ราคานี้ไม่ใช่ข้อเสนอทางการค้า"}
   ```

3. พิสูจน์ว่าราคามาจาก server. เพิ่ม field ที่ Worker ไม่อนุญาต เช่น `"totalPrice":1` แล้วส่งอีก key; จะได้ `400 UNKNOWN_FIELD`. หรือเปลี่ยน text/total ใน DevTools แล้ว submit from browser: response ยังคำนวณจาก `productId` + `quantity` และ D1 product price. Worker ไม่รับ `unitPrice`, `totalPrice`, `stock` หรือ `status` จาก client.

4. ตรวจ local D1 หลัง success:

   ```bash
   npx wrangler d1 execute DB --local --command \
     "SELECT id, total_price, name, email, status FROM quotes ORDER BY created_at DESC LIMIT 3"
   npx wrangler d1 execute DB --local --command \
     "SELECT quote_id, product_id, quantity, unit_price FROM quote_items"
   ```

   **ผลที่คาดหวัง:** `total_price=918000`, `unit_price=459000`, `status=quote-demo`, และ contact fields เป็น `[demo-redacted]`. การเก็บ item price snapshot ช่วยให้เห็นว่า quote เก่ามีหลักฐานราคาตอนสร้าง แม้เป็น demo.

5. ลอง error contract จริง: ส่ง items ซ้ำจะได้ `400 DUPLICATE_PRODUCT`; ส่ง `productId` ที่ไม่มีจะได้ `400 INVALID_PRODUCT`; ส่ง POST โดยไม่มี Origin ได้ `403 ORIGIN_REJECTED`. ทุก API error มีรูป `{ "error": { "code": "…", "message": "…" } }`; client ที่พัฒนาต่อควรอ่าน `error.message`.

6. ทดสอบ UI: เพิ่มสินค้าสองรายการ, เปลี่ยน quantity, submit form ด้วย identity demo, แล้วเปิด network response. สุดท้ายรัน:

   ```bash
   npm run typecheck
   npm run test
   npm run build
   ```

## Demo กับ payment/commerce จริง

| เรื่อง | DEMO_MODE=true ตอนนี้ | เมื่อพร้อมรับเงินจริง |
| --- | --- | --- |
| quote | `quote-demo`, local D1, PII redacted | กำหนด lifecycle quote/order, retention, authorization และ notification |
| catalog price | D1 seeded catalog เป็น authority และ snapshot D1 หลัง quote | source-of-truth catalog/price ที่มี admin authorization, currency/tax/stock rules |
| payment | ไม่มี card field, provider หรือ paid state | Worker สร้าง provider session จาก server order; browser ไม่เป็น authority |
| fulfillment | ไม่มี | process verified webhook แบบ idempotent แล้วค่อย fulfil |

ส่วนขวาเป็น **optional production implementation**, ไม่ใช่สิ่งที่ lab ปัจจุบันทำ. ถ้าเลือก Stripe ในอนาคต ให้ Worker สร้าง Checkout Session server-side; Checkout มี hosted/embedded UI และจัดการ checkout state ([Stripe Checkout](https://docs.stripe.com/payments/checkout), [Checkout Sessions](https://docs.stripe.com/payments/checkout-sessions)). ห้าม mark paid จาก `success_url`: webhook ต้อง verify raw payload, `Stripe-Signature` และ endpoint secret ก่อนเปลี่ยน order state ([Stripe webhooks](https://docs.stripe.com/webhooks)).

## คุณภาพและข้อผิดพลาดที่พบบ่อย

เป้าหมาย field data p75 คือ LCP ≤2.5s, INP ≤200ms, CLS ≤0.1; Lighthouse เป็น lab signal ไม่ใช่ data ผู้ใช้จริง ([web.dev LCP](https://web.dev/articles/optimize-lcp), [INP](https://web.dev/articles/optimize-inp), [CLS](https://web.dev/articles/optimize-cls)). Hero image จึงควรมี dimension และไม่ lazy-load หากเป็น LCP candidate. WCAG 2.2 ให้ target pointer อย่างน้อย 24×24 CSS px ตามกรณีปกติ และ interaction ที่ต้องลากต้องมีทางเลือก ([WCAG 2.2](https://www.w3.org/TR/WCAG22/)).

| อาการ | สาเหตุ | วิธีแก้ |
| --- | --- | --- |
| motion เล่นเมื่อ Reduce Motion เปิด | เพิ่ม GSAP นอก `matchMedia` | สร้าง motion เฉพาะ `no-preference` และตรวจ final static state |
| sound stage pin บนมือถือหรือจอเตี้ย | media query ตรวจแค่ความกว้าง | กำหนดทั้ง `min-width:1024px`, `min-height:700px` และ `no-preference` |
| animation ทวีคูณหลัง re-mount | ไม่ revert context/listener | ใช้ `ctx.revert()`/`mm.revert()` และ remove listener |
| ปุ่มบรรยากาศทำเหมือนมีเสียงจริง | copy หรือ code ทำเกิน visual demo | ใช้ `aria-pressed` + visualizer + status เท่านั้น ไม่เรียก audio/microphone |
| UI บอก “ซื้อสำเร็จ” | สับสน `quote-demo` กับ payment | ใช้ copy “ใบเสนอราคาสาธิต”; อย่าเพิ่ม paid status |
| `400 UNKNOWN_FIELD` | client ส่ง total/price หรือ field เกิน contract | ส่งเฉพาะ `items,name,email,consent,website` (และ Turnstile non-demo) |
| D1 มี contact จริง | ไม่ควรเกิดเมื่อ `DEMO_MODE=true` | ตรวจ config; หยุดและแก้ environment ก่อนทดสอบต่อ |

## แบบประเมิน

สาธิต storyboard ว่าแต่ละ section ตอบคำถามผู้ซื้ออะไร, desktop pin เทียบกับ natural flow, Reduce Motion, listening scenarios แบบ visual-only, keyboard drawer, `GET /api/catalog`, quote 201 และ D1 quote snapshot. ผู้เรียนต้องอธิบายได้ว่า integer satang `totalPrice` อ่านจาก D1 product catalog ของ **current demo**, ต่างจาก browser preview และต่างจาก checkout/payment มีผลผูกพันอย่างไร.

อ่านต่อ: [มาตรฐานคุณภาพและ commerce](../research/web-quality-commerce.md) และ [บท deploy](08-cloudflare-deploy.md).
