เมนูสาธารณะ (Menu Builder)
ภาพรวม
หน้าเมนูสาธารณะแสดง "เมนู" ที่แอดมินออกแบบไว้ด้วย menu builder ในฝั่ง CMS พฤติกรรมของหน้านี้ใกล้เคียงกับแอปเมนูขนาดย่อม คือผู้ใช้กดเข้าและย้อนกลับได้หลายชั้น ความสามารถที่รองรับได้แก่ ปุ่ม 4 ชนิด การกรองรายการตามกลุ่มผู้ใช้ ธีมและสไตล์ที่กำหนดได้ระดับรายปุ่ม รูปพื้นหลังและไอคอน และการค้นหาข้ามทั้งเมนูซึ่งครอบคลุมถึงเนื้อหาบทความที่อยู่ภายใน content link แบบ inline ด้วย
หน้านี้เป็นไฟล์ที่ใหญ่ที่สุดในโปรเจกต์ ด้วยขนาดประมาณ 1,170 บรรทัด เนื่องจากรวมทั้งตรรกะการนำทาง การเรนเดอร์สไตล์ และกลไกการค้นหาไว้ในที่เดียว
Business Flow
- ผู้ใช้เข้าสู่ path
/:hash/menu/:token - ระบบยิงคำขอดึงข้อมูลเมนู แบบยังไม่แนบ token ก่อน เพื่อดูการตั้งค่าเมนูและค่า LIFF ID ที่ต้องใช้
- ตรวจสอบแบบ recursive ว่ามี menu item ใดตั้งเงื่อนไขกลุ่มผู้ใช้ไว้หรือไม่
- หากไม่มี ระบบจะข้ามขั้นตอน LIFF ทั้งหมด เพราะไม่จำเป็นต้องรู้ว่าผู้ใช้เป็นใคร ทำให้เมนูเปิดได้เร็ว
- หากมี ระบบจะทำการล็อกอิน LIFF แล้วดึง ID token
- ยิงคำขอดึงข้อมูลเมนูอีกครั้งพร้อมแนบ
x-liff-tokenเพื่อให้ API กรองรายการเมนูตามกลุ่มผู้ใช้ให้เรียบร้อยก่อนส่งกลับมา - ตั้งชื่อ document title ตามชื่อเมนู
- เรนเดอร์ปุ่มตาม template ที่ตั้งไว้ (แบบตารางหรือแบบรายการ) ประกอบกับธีมของเมนูและสไตล์เฉพาะของแต่ละปุ่ม ครอบคลุมสี gradient ความโค้งมุม เงา ความสูง ตำแหน่งข้อความ และรูปภาพที่ใช้เป็นพื้นหลัง ไอคอน หรือเต็มปุ่ม
- เมื่อผู้ใช้แตะปุ่ม ระบบทำงานตามชนิดของ item ตามตารางด้านล่าง
- ปุ่มย้อนกลับและปุ่มกลับหน้าแรกจัดการเส้นทางการนำทางปัจจุบันและสถานะการแสดงรายการเนื้อหาแบบ inline
พฤติกรรมของปุ่มแต่ละชนิด
| ชนิด | พฤติกรรม |
|---|---|
| เมนูย่อย | เข้าสู่เมนูชั้นถัดไปภายในหน้าเดิม |
| ลิงก์ภายนอก | เปิด URL ที่กำหนดในแท็บใหม่ |
| หน้าบทความ | พาไปยังหน้าอ่านบทความของ token นั้น |
| Content link แบบเปิดหน้าใหม่ | พาไปยังหน้ารายการเนื้อหาของลิงก์นั้น |
| Content link แบบ inline | โหลดรายการเนื้อหามาแสดงเป็นเมนูย่อยในหน้าเดิม โดยเลือกได้ว่าจะแสดงเป็นการ์ดหรือแบบปุ่ม |
กลไกการค้นหา
เมื่อผู้ใช้พิมพ์คำค้นตั้งแต่ 2 ตัวอักษรขึ้นไป ระบบจะค้นหาชื่อและ URL ของ menu item แบบ recursive ทุกชั้น
พร้อมกันนั้นระบบจะดึงเนื้อหาของ content link แบบ inline ทุกรายการมาเก็บไว้ใน cache เพื่อให้ผู้ใช้ค้นหาจากหัวข้อและข้อความเกริ่นนำของบทความได้ด้วย ผลลัพธ์ที่เป็นบทความจะถูกแปลงเป็นโครงสร้าง menu item ชั่วคราวเพื่อให้แสดงผลในรายการเดียวกับ menu item ปกติได้อย่างกลมกลืน
หน้าจอและองค์ประกอบหลัก
หน้าเพจ
- หน้าเมนู (
src/app/[hash]/menu/[token]/page.tsx) รวมตรรกะทั้งหมดไว้ ได้แก่ การจัดการการแตะปุ่ม การคำนวณรายการที่ต้องแสดงในชั้นปัจจุบัน การประกอบสไตล์ของปุ่ม การเรนเดอร์เนื้อหาภายในปุ่ม การค้นหา content link แบบ inline ทั้งหมด การดึงเนื้อหามาเข้า cache สำหรับค้นหา การกรองรายการ การแปลงบทความเป็น menu item และการจัดการปุ่มย้อนกลับกับปุ่มกลับหน้าแรก - คอมโพเนนต์ย่อยสำหรับแสดงบทความในรูปแบบการ์ดและรูปแบบปุ่มถูกนิยามไว้ในไฟล์เดียวกัน
Hook, service และชนิดข้อมูล
- Hook ดึงข้อมูลเมนู (
src/app/menu/[token]/hooks/useFetchMenuByToken.ts) ใช้เพียงสถานะว่ามี token หรือไม่เป็นส่วนหนึ่งของ query key แทนการใช้ค่า token จริง เพื่อลดการ refetch ที่ไม่จำเป็น - Content service สำหรับเมนู (
src/app/menu/[token]/services/content.service.ts) ดึงรายการบทความตาม token ของลิงก์ และแปลงข้อมูลจาก API ให้เป็นรายการเนื้อหา โดยเลือกคำแปลภาษาไทยก่อน แล้ว fallback เป็นภาษาอังกฤษ และท้ายที่สุดเลือกคำแปลตัวแรกที่มีหัวข้อ - Menu builder service (
src/service/menu-builder.service.ts) เป็นตัวติดต่อ endpoint ดึงข้อมูลเมนู - ชนิดข้อมูลของ menu item ชนิดการกระทำ การตั้งค่าเมนู ธีม template และรูปแบบการแสดง content link อยู่ใน
src/app/menu/types/menu.type.ts
Endpoint ที่ใช้
GET /menu-builder/:tokenโดย headerx-liff-tokenเป็นทางเลือกGET /public-content/links/:linkToken/contentsสำหรับดึงรายการเนื้อหาแบบ inline
โฟลเดอร์ src/app/menu/ เหลือเพียงส่วน hook, service และ type เท่านั้น ไม่มีไฟล์หน้าเพจอีกแล้ว เนื่องจาก route จริงถูกย้ายไปอยู่ภายใต้ segment ของ hash ทั้งหมด
จุดเชื่อมต่อกับฟีเจอร์อื่น
- Ant Design ให้คอมโพเนนต์ช่องค้นหา ปุ่ม ระบบ grid และตัวหมุนรอโหลด ร่วมกับชุดไอคอนของ Ant Design
- การล็อกอิน LINE ผ่าน LIFF ถูกเรียกใช้แบบมีเงื่อนไข คือเฉพาะเมื่อเมนูมีการตั้งค่ากรองตามกลุ่มผู้ใช้
- ปลายทางของการนำทางคือ หน้าอ่านบทความ และ หน้ารายการเนื้อหา
- ธีมและการตั้งค่าทั้งหมดมาจาก API ฝั่ง client ไม่มีการ hard-code นอกจากค่าเริ่มต้นที่ใช้เมื่อ API ไม่ส่งค่ามา
รายละเอียดฝั่ง Backend (Client API)
การกรองตาม audience เป็นด่านจริง ไม่ใช่แค่ซ่อน UI
นี่คือจุดที่สำคัญที่สุดของ endpoint นี้: menu item ที่ผู้ใช้ไม่มีสิทธิ์เห็นจะไม่ถูกส่งออกมาจาก backend เลย ทั้งชื่อ ชนิด และ URL ปลายทาง — ไม่ใช่การส่งมาครบแล้วให้หน้าเว็บซ่อน
ผลที่ตามมา:
- การเปิด DevTools ดู response ก็ไม่เห็นเมนูที่ไม่มีสิทธิ์ ข้อมูลไม่รั่วออกมาตั้งแต่ต้นทาง
- ในทางกลับกัน หน้าเว็บ ไม่มีทางรู้ว่ามีเมนูที่ถูกกรองออกไปกี่รายการ จึงแสดงข้อความประเภท "มีเมนูที่คุณไม่มีสิทธิ์เห็น" ไม่ได้
- การเปรียบเทียบผลลัพธ์ก่อนและหลังล็อกอิน (ซึ่งหน้านี้ยิง 2 ครั้งอยู่แล้ว) เป็นวิธีเดียวที่จะเห็นความต่าง
กฎการกรองแบบ recursive
backend เดินต้นเมนูจากล่างขึ้นบน:
- กรอง children ก่อนเสมอ แล้วค่อยตัดสินตัวแม่
- item ที่ ไม่ได้กำหนด audience → ทุกคนเห็นได้; ที่กำหนดไว้ → เห็นได้เมื่อมี audience ทับกันอย่างน้อย 1 กลุ่ม (เป็น OR ไม่ใช่ AND)
- โฟลเดอร์ (เมนูย่อย) ที่ลูกถูกกรองออกจนหมด จะถูกตัดออกไปด้วย — ผู้ใช้จึงไม่เจอโฟลเดอร์ว่างที่กดเข้าไปแล้วไม่มีอะไร นี่เป็นพฤติกรรมที่ตั้งใจและควรรู้ เพราะทำให้จำนวนปุ่มในชั้นเดียวกันต่างกันไปตามผู้ใช้
- backend แยกแยะระหว่าง "item ที่ไม่มี property children เลย" (key จะหายไปจาก JSON) กับ "item ที่มี children เป็น array ว่าง" (ส่ง array ว่างออกมา) — ฝั่งเว็บต้องเผื่อทั้งสองกรณี ไม่ควรสมมติว่า children มีอยู่เสมอ
- item ที่รอดการกรองถูกประกอบใหม่ด้วยชุด key ที่คงที่เสมอ (18 ฟิลด์ ครอบคลุมชื่อ ชนิด URL token ของบทความและ content link พฤติกรรมและรูปแบบการแสดง content link ไอคอน ระดับชั้น ลำดับ สไตล์ รูปภาพ และการแสดงข้อความ) — โครงสร้างจึงคาดเดาได้แน่นอน
การ verify token เป็นแบบ optional และกลืน error ทั้งหมด
- header
x-liff-tokenเป็น ทางเลือก ไม่ส่งมาก็เรียกได้ - ถ้าส่งมาแต่ verify ไม่ผ่าน (หมดอายุ ผิด channel ฯลฯ) backend กลืน error ทั้งหมดแล้วปฏิบัติเหมือนผู้ใช้ไม่ได้ล็อกอิน คือเห็นเฉพาะ item ที่เป็น public — ไม่เคยตอบ 401
- นี่คือเหตุผลที่การยิงครั้งแรกโดยไม่แนบ token (ซึ่งหน้านี้ทำ) ปลอดภัยและใช้งานได้จริง
- ข้อควรระวัง: ถ้า token เสียโดยที่หน้าเว็บไม่รู้ ผู้ใช้จะเห็นเมนูไม่ครบแบบเงียบๆ ไม่มี error ให้จับ — เวลามีรายงานว่า "เมนูหาย" ให้ตรวจสถานะ token ก่อนตรวจการตั้งค่าเมนู
- เมนูที่ไม่พบจาก token → 404
Menu not found
รายละเอียดของ response ที่มีผลกับหน้าเว็บ
- backend ส่ง
lineOaHashและlineLiffIdมาพร้อมโครงเมนู หน้าเว็บจึงไม่ต้องเรียก endpoint แปลง hash แยกเพื่อเริ่มล็อกอิน lineLiffIdแยกแยะระหว่างสตริงว่างกับ null อย่างจงใจ — ค่าที่แอดมินตั้งเป็นสตริงว่างจะยังคงเป็นสตริงว่าง ไม่ถูกแปลงเป็น null; จะเป็น null เฉพาะเมื่อ OA ไม่มีข้อมูลการตั้งค่า LINE Login หรือไม่มีคีย์นั้นจริงๆ ฝั่งเว็บจึงควรเช็คให้ครอบคลุมทั้งสองกรณีก่อนนำไป init LIFFtemplateและthemeถูกส่งผ่านเป็น JSON ดิบตามที่เก็บไว้ backend ไม่ validate และไม่เติมค่า default ให้เลย — การตีความค่าที่ขาดหายและการกำหนดค่าเริ่มต้นเป็นหน้าที่ของฝั่งเว็บทั้งหมด ซึ่งตรงกับที่หน้านี้ทำอยู่
Edge case ที่ควรรู้
- ไม่มี cache ที่ endpoint นี้ ทุกครั้งที่หน้าเว็บยิง (รวมถึงการยิงซ้ำหลังล็อกอิน) เป็นการอ่านฐานข้อมูลจริง — การยิง 2 ครั้งต่อการเปิดหนึ่งครั้งจึงมีต้นทุนเป็น 2 เท่า และถูกนับใน rate limit ระดับแอปด้วย
- endpoint นี้ ไม่นับสถิติการเปิดเมนู ต่างจาก content link ที่นับ click count ทุกครั้ง
- รายการเนื้อหาแบบ inline ที่หน้านี้ดึงมาแสดงในเมนูใช้ endpoint ของ content link ซึ่ง มี rate limit เข้มกว่า (10 ครั้งต่อ 60 วินาทีต่อ IP) และนับ click count ทุกครั้ง — การดึงเนื้อหาทุกลิงก์มา cache เพื่อทำ search จึงทำให้ตัวเลขคลิกของลิงก์เหล่านั้นเพิ่มขึ้น และมีโอกาสชน rate limit ถ้าเมนูมี content link จำนวนมาก (ดู หน้ารายการเนื้อหา)
- การกรอง audience อาศัยข้อมูลผู้ใช้ที่ผูกกับ OA ของเมนูนั้น — ผู้ใช้ใหม่ที่เพิ่งเปิดครั้งแรกจะถูกสร้างเป็น guest อัตโนมัติและยังไม่มี audience ใดๆ จึงเห็นเฉพาะเมนู public จนกว่าจะถูกจัดกลุ่ม