Architecture & Technical Handbook

น้องนัดที (NongNutTee)

เอกสารเทคนิครวมเล่มเดียว: สถาปัตยกรรมจากโค้ดจริง + tech plan + infra/ราคา + design brief + สเปก UX รายจอ — จุดที่เอกสารเดิมล้าสมัยกว่าโค้ดถูกแก้ให้ตรงแล้ว (§12) · อ่านคู่กับ prd.md, pitch.md และ prototype ใน demo/ ซึ่งเป็น source of truth ของพฤติกรรมทุกจอ

ฉบับ 2.1 · 14 ส.ค. 2026ต้นฉบับ: docs/prd/architecture.mdPhase A เสร็จ · เหลือ deploy
สารบัญ
ส่วน A
§1 ภาพรวม§2 หลักการ§3 Context§4 repo§5 apps/web§6 shared§7 Adapter§8 ข้อมูล§9 Cross-cutting§10 Testing§11 Deployment§12 ส่วนต่าง§13 ความเสี่ยง§14 ADR
ส่วน B
§15 Stack§16 API Contract§17 แผนเทสต์§18 Milestones§19 กติกากันพัง§20 Schema§21 เครื่องมือ
ส่วน C
§22 Tiers§23 Infra รายชิ้น§24 Monitoring§25 บิลรวม§26 ทริกเกอร์
ส่วน D
§27 แบรนด์§28 Flex vs LIFF§29 Decisions
ส่วน E
§30 Prototype§31 สเปกรายจอ§32 Interactions§33 Tokens§34 Assets
ส่วน A

สถาปัตยกรรมจากโค้ดจริง

§1ภาพรวมและขอบเขต

น้องนัดทีคือ LINE Bot + LIFF app สำหรับนัดหมายกลุ่ม: สร้างนัด → เพื่อนกดเข้าร่วมจากการ์ดในแชต → ช่วงใกล้นัดเปิดแผนที่รวมดูว่าใครถึงไหน → บันทึกเวลาถึงจริง คิดค่าปรับคนสาย → สรุปผลต่อนัด/ต่อปี

การพัฒนาแบ่งสองเฟส และสถาปัตยกรรมทั้งหมดถูกออกแบบรอบรอยต่อระหว่างสองเฟสนี้:

Phase A (ทำเสร็จแล้ว)Phase B (แผน — ยังไม่เริ่ม)
ขอบเขตจอ LIFF ครบ 13 จอ + business logic + E2EBackend จริง + LINE webhook + bot + scheduler + Stripe
ข้อมูลMockApiClient in-memory + localStoragePostgres (Supabase) ผ่าน REST (Next Route Handlers)
ตัวตน/พิกัดmock ทั้งหมด (MockLiffService, MockGeoService)LIFF SDK จริง + navigator.geolocation
เงื่อนไขเหล็กจอที่ทำใน Phase A ต้องไม่ถูกแก้ — สลับ adapter เท่านั้น

§2หลักการออกแบบ (ตัวขับการตัดสินใจทั้งระบบ)

  1. Ports & Adapters — จอห้ามรู้จักแหล่งข้อมูล ทุกจอเข้าถึงข้อมูล/ตัวตน/พิกัด/เวลา ผ่าน interface 4 ตัว (ApiClient, LiffService, GeoService, Clock) เท่านั้น
  2. Business logic เขียนครั้งเดียวที่ packages/shared — ค่าปรับ/โควตา/กันนัดซ้อน/shareLead ใช้ร่วมกันทั้ง UI, mock และ backend อนาคต จะไม่มีวันเพี้ยนกันเอง และสิทธิ์ฟรีต้อง clamp ที่ shared เสมอ (ไม่เชื่อ client)
  3. Derive, don't store — โควตาเดือน, no-show, สถานะ live, สถิติรายปี ไม่มีตารางเก็บ คำนวณจากข้อมูลดิบเสมอ (กันบั๊ก sync)
  4. SPA-first — LIFF ไม่ต้องการ SSR/SEO ทุกหน้าเป็น client component (ยกเว้น / ที่ redirect) เหตุผลที่ยังใช้ Next.js: Phase B ได้ Route Handlers เป็น REST + webhook ในโปรเจกต์เดียว
  5. Privacy โดยโครงสร้าง (PDPA) — พิกัดถูกลบเมื่อนัดจบ เหลือแค่เวลาถึง พฤติกรรมนี้เป็นจริงแม้ใน mock และมี backstop จบนัดอัตโนมัติ T+2 ชม.
  6. เวลาเก็บ UTC ISO เสมอ — แปลง พ.ศ./เวลาไทย (Asia/Bangkok) ที่ชั้น UI เท่านั้น ส่วน logic ที่อิง "วันไทย" (โควตาเดือน, กันนัดซ้อน) แปลง timezone ภายใน shared

§3System Context

flowchart LR
  subgraph now["Phase A (ปัจจุบัน)"]
    U[ผู้ใช้บนเบราว์เซอร์/มือถือ] --> W["apps/web (Next.js SPA)"]
    W --> M["MockApiClient + localStorage"]
    W --> OSM[OpenStreetMap tiles]
  end
  subgraph phaseB["Phase B (แผน)"]
    U2[ผู้ใช้ใน LINE] --> LIFF["LIFF app (จอชุดเดิม)"]
    LINE[LINE Platform] -->|webhook| API["app/api (Route Handlers)"]
    LIFF -->|HttpApiClient| API
    API --> DB[(Postgres/Supabase)]
    WK["apps/worker (scheduler)"] --> DB
    WK -->|push| LINE
    API --> STR[Stripe PromptPay]
  end

ขอบเขต Phase B เชิงสัญญา (endpoints, schema, jobs) นิยามไว้ที่ §16 และ §20

§4โครงสร้าง repo (npm workspaces monorepo)

nongnuttee/
  apps/web/           # @nnt/web — Next.js 15 App Router, จอทั้งหมด (Phase B เพิ่ม app/api)
  packages/shared/    # @nnt/shared — types + ports + business logic + unit tests
  docs/prd/           # เอกสารชุดนี้ · docs/issue/ — ประเด็นค้างที่ตัดสินใจแล้ว
  demo/               # prototype HTML (source of truth ของ UX) + pitch deck
  CLAUDE.md           # กติกา repo
  • @nnt/shared ส่งออกเป็น raw TypeScript (exports ชี้ src/index.ts ไม่มี build step) → apps/web ใช้ผ่าน transpilePackages ใน next.config.ts
  • ยังไม่ถูกสร้าง (แผน Phase B): apps/worker, packages/flex
  • สคริปต์ราก: npm run dev (web) · npm test (shared/vitest) · npm run typecheck (ทุก workspace)

§5apps/web — โครงสร้างภายใน

5.1 Routing (App Router — จอทั้งหมดอยู่ใต้ /g/[groupId])

Routeจอ (รหัสตาม prototype)
/redirect → /g/g-demo (hardcode เฉพาะ mock — ดู issue 003)
/g/[groupId]L1 หน้าแรก: ติดตามนัด (live) + timeline นัดของกลุ่ม + โควตา + footer
…/welcome · …/permit · …/deniedO1–O3 onboarding + ขออนุญาตตำแหน่ง + กรณีไม่อนุญาต
…/create → …/create/fineL1-c → L1-b ฟอร์มสร้างนัด 2 ขั้น (ขั้น 2 รวม flow แก้ไข + dup dialog)
…/placeL1-p เลือก/ปักหมุดสถานที่ (เขียนลง draft store แล้ว router.back())
…/events/[eventId]L1-i รายละเอียดนัด + RSVP 3 สถานะ + ล็อกปุ่มแผนที่จนถึง shareLead
…/events/[eventId]/mapL2 แผนที่ Leaflet + consent sheet + สถานะ idle/moving/arrived + เช็คชื่อจบนัด (ไฟล์ใหญ่สุด ~670 บรรทัด)
…/events/[eventId]/manageL1-r แก้ไขนัด + ยกเลิกนัด
…/events/[eventId]/recapL6 สรุปนัด 2 แท็บ + podium
…/historyL5 ประวัติ + ค้นหา (debounce 300ms)
…/statsL3 สถิติรายปี + รางวัล 6 หมวด
…/proL4 paywall (จ่าย mock → Pro เปิดทันที)

มี layout เดียวคือ root (lang="th", โหลด Noto Sans Thai ผ่าน next/font) — ไม่มี nested layout, loading.tsx, error.tsx หรือ API route ใดๆ ใน Phase A

กติกา route/URL (ตกลง ส.ค. 2026 — issue 004): segment เป็นคำเต็มภาษาอังกฤษเสมอ ห้ามย่อ · มุมมองของนัดซ้อนใต้ events/[eventId]/ · จอใหม่ต้องเพิ่มลงตารางข้างบนเสมอ · ID ใน path ต้อง opaque เท่านั้น (uuid / LINE id) · ห้ามมี PII/พิกัดใน path หรือ query (URL หลุดเข้า log เสมอ — โยง PDPA) · URL ไม่ใช่แหล่งสิทธิ์ (issue 003) · Route ตรึงแล้วเมื่อ launch — การ์ด Flex ในแชตลิงก์ path ถาวร

5.2 Components

  • src/components/ui.tsx — design system ทั้งหมดในไฟล์เดียว: ScreenShell (กรอบ fluid เต็มจอถึงเพดาน --container-frame 480px — 375 คือฐานออกแบบ ไม่ใช่ขนาดล็อก (issue 009), header/footer ตรึง เนื้อหา scroll, footer หลบ safe area iPhone), TopBar, FooterBar, PrimaryButton/OutlineButton, Avatar (สี deterministic จาก userId), EventPhasePill, ToastBar, Dialog
  • src/components/TimeSheet.tsx — bottom sheet เลือกวัน+เวลา (นาทีทีละ 5) ใช้ร่วมจอ create/manage
  • UI เฉพาะจอ (bottom sheets, roll-call, consent, podium ฯลฯ) inline อยู่ในไฟล์ page ของจอนั้น — ไม่แยกเป็น component กลางจนกว่าจะถูกใช้ซ้ำ

5.3 State

ชนิดที่อยู่หมายเหตุ
Server state (นัด/สมาชิก/live)useState + useEffect ต่อจอ เรียก getApi() ตรงๆไม่มี react-query/SWR/cache — ทุกครั้งที่เข้าจอ refetch ใหม่ (mock หน่วง 250ms)
Draft ฟอร์มสร้าง/แก้นัดzustand useDraftแชร์ข้ามจอ create → place → fine → TimeSheet → manage · persist ลง sessionStorage — ร่างรอด LIFF reload (issue 001 ปิดแล้ว)
Toastzustand useToastauto-clear 4 วิ แสดงผ่าน ToastBar
NavigationNext router ปกติflow ทางเดียวใช้ replace — back ไม่ย้อนเข้าจอที่จบ (issue 002 ปิดแล้ว)

ไม่มี React context เลยทั้งแอป · ตัวจับเวลา: จอ detail tick ทุก 30 วิ, จอแผนที่ poll getLive ทุก 20 วิ + tick 5 วิ (ตามมติ "polling พอ ไม่ทำ websocket")

§6packages/shared — โดเมนและ business logic

โมดูลหน้าที่ค่าคงที่/กติกาสำคัญ
types.tsโดเมนไทป์ทั้งหมด (EventDetail, FineRule, Recap, YearStats, …)CreateEventResult เป็น discriminated union กับ DuplicateWarning
ports.tsinterface 4 ตัว (สัญญาของทั้งระบบ — ดู §7)
fine.tsคำนวณค่าปรับ + clamp สิทธิ์ผ่อนผัน 5 นาทีเสมอ · ฟรี: 1฿/นาที เพดาน 50 หรือเหมา 50 · Pro: สูงสุด 99/นาที, เหมา 999
quota.tsโควตาฟรี 3 นัด/เดือน (นับตามเดือนไทย)นับจาก events ที่ไม่ถูกยกเลิก — ยกเลิก = คืนโควตาโดยธรรมชาติ
dup.tsกันนัดซ้อน — นัด upcoming/active วันไทยเดียวกันadvisory: UI ให้ยืนยัน ไม่ hard-block
shareLead.tsหน้าต่างเริ่มแชร์ตำแหน่งฟรี 60 นาที · Pro สูงสุด 10,080 (7 วัน) · isMapUnlocked()
eventPhase.tsสถานะจอ: waiting / started / endedderive จาก startsAt + status + Clock
payee.tssanitizePayee — ช่องทางรับเงิน 3 แบบ (issue 011)ตัด field ที่ไม่ตรง method · default ฝั่งจอ = promptpay
distance.tsdistanceKm (haversine) สำหรับ live indicator (issue 005)แทน ETA — ขั้นบันไดแสดงผลฝั่งจอ

ทุกโมดูลเป็น pure function มี unit test (vitest) ครอบ รวม edge case timezone (17:30 UTC = ข้ามวันไทย) — จอและ mock import ฟังก์ชันชุดเดียวกันนี้ ไม่เขียน logic ซ้ำ

§7ชั้น Adapter (หัวใจของ "พร้อมต่อ Phase B")

flowchart TD
  P["จอทั้งหมด (client components)"] -->|"getApi() getLiff() getGeo() getClock()"| F["adapters/index.ts<br/>(composition root — singleton ต่อ interface)"]
  F -->|NEXT_PUBLIC_ADAPTER=mock| MA["MockApiClient<br/>in-memory + localStorage 'nnt-mock-state' · หน่วง 250ms"]
  F -->|NEXT_PUBLIC_ADAPTER=http| HA["HttpApiClient — ยังไม่มี (throw)<br/>Phase B เขียนตาม contract §16"]
  MA -.implements.-> PORT["ApiClient (packages/shared/ports.ts)"]
  HA -.implements.-> PORT
  MA --> SEED["seed.ts + seedHistory.ts<br/>ชุดข้อมูลเดโม ครอบทุก UI variant"]
  • ApiClient มี 17 เมธอด ครอบทุกการอ่าน/เขียน — นิยามจริงอยู่ packages/shared/src/ports.ts และถือเป็น source of truth ของสัญญา: getGroup · listEvents · listPlaces · getEvent · createEvent(input, {force?})CreateEventResult · updateEvent · cancelEvent · endEvent(eventId, attendance[]) (เช็คชื่อ) · rsvpEventDetail · checkin(eventId, LatLng|null) · markArrived · getLive · getRecap · listStatYears · getYearStats · getSubscription · startCheckout
  • LiffService (โปรไฟล์/in-client/close), GeoService (getPosition/watch/permissionState — mock คือจุดวิ่งตามสคริปต์ทุก 3 วิ), Clock (ทำให้เทสต์ countdown/ค่าปรับด้วยเวลาปลอมได้)
  • จุดสลับ Phase B มีจุดเดียว: apps/web/src/lib/adapters/index.ts — ไม่มีจอไหน import adapter ตัวจริงตรงๆ · สวิตช์ผ่าน env NEXT_PUBLIC_ADAPTER=mock|http (default mock)
  • MockApiClient ไม่ใช่ stub แต่จำลองพฤติกรรมระบบจริงตาม contract: dup-check, คืนโควตาเมื่อยกเลิก, auto-end (ทุกคนถึงครบ / T+120 นาที), สร้าง recap ตอนจบพร้อมลบพิกัด, clamp สิทธิ์ผ่าน shared — Phase B ต้อง replicate พฤติกรรมชุดนี้ฝั่ง server

§8ข้อมูลและ persistence

  • Phase A: state ทั้งก้อนเดียว (group, events, self, recaps) อยู่ใน localStorage key nnt-mock-state — ล้าง = reset เดโม (E2E ทุก spec ล้างก่อนรันเสมอ)
  • Phase B: Postgres 10 ตาราง — ER diagram + SQL + เหตุผลเชิงโครงสร้าง (idempotent PK, TTL พิกัด, unique กัน push ซ้ำ) อยู่ §20
  • ไม่มี user upload โดยตั้งใจ (ตัด storage/moderation/PDPA ทั้งชุด) — QR PromptPay เจนจากเบอร์, avatar hotlink จาก LINE CDN

§9Cross-cutting concerns

เรื่องแนวทางที่ใช้จริง
เวลา/เขตเวลาmodel = UTC ISO เสมอ · lib/format.ts แปลง พ.ศ./ไทยผ่าน Intl.DateTimeFormat(timeZone: "Asia/Bangkok") · logic วันไทยอยู่ใน shared
Privacy (PDPA)consent sheet ก่อนแชร์ครั้งแรก · จอ O2/O3 อธิบายก่อนขอจริง · พิกัดลบเมื่อนัดจบ (ทำจริงใน mock) · ใช้งานต่อได้แบบไม่แชร์พิกัด ("กดถึงแล้วเอง")
Design tokensTailwind v4 แบบ CSS-first — token ~30 ตัวใน @theme ของ globals.css ต้อง sync กับ §33 · ห้าม hex ใหม่นอก token · ตัวเลขใส่ class .num (tabular-nums)
ฟอนต์Noto Sans Thai self-host ผ่าน next/font (400/500/700/900)
แผนที่Leaflet โหลดแบบ dynamic import ในจอแผนที่ · tiles จาก OSM สาธารณะ — เส้นทางย้ายตามสเกลอยู่ §23.5 · เปลี่ยนแหล่ง tiles = แก้ URL จุดเดียว
Feedbacktoast กลางระบบ (โทน "ค้าบ/ครับ") + dialog ยืนยันทุก action ทำลายล้าง (ยกเลิกเข้าร่วม/ยกเลิกนัด/นัดซ้อน)
Error handlingmock แทบไม่ล้มเหลว จอจึงยังไม่มีชั้น error/retry จริงจัง — ต้องเติมตอน HttpApiClient (ดู §13)

§10Testing

ชั้นเครื่องมือสถานะจริง
Unit — business logicvitest ใน packages/sharedทำแล้ว ครอบ fine/quota/dup/shareLead/eventPhase รวม edge timezone
Componentยังไม่มี (§17 วางไว้ ยังไม่ถูกทำ)
E2EPlaywright, iPhone 13, port 310015 เคส journeys J1–J6 · draft persist ×2 (001) · responsive 360/430 (009) · วิธีรับเงิน J7–J8 (011) · place/นำทาง/สลับโหมดแผนที่ ×3 (005–008)
Visual snapshotPlaywright toHaveScreenshot4 จอ L1, L1-i, L2, L6 ที่ 375 — ตรึงเวลา page.clock + mask tile pane

ช่องว่าง E2E ที่รู้ตัว: onboarding, แก้ไข/ยกเลิกนัด, consent+แชร์พิกัดจริง, ค้นหาประวัติ

§11Deployment

  • ปัจจุบัน: ยังไม่ deploy — งานสุดท้ายของ A8 ค้างที่ต้องใช้บัญชี Vercel/Cloudflare ของเจ้าของโปรเจกต์ (npm run build พร้อมแล้ว ไม่มี server dependency)
  • แผนตามสเกล: Vercel (front+API) + Supabase + Railway worker + Cloudflare DNS — ตารางราคา/ทริกเกอร์อัปเกรดอยู่ §22–26 (ต้นทุนก้อนใหญ่สุดคือ LINE OA ไม่ใช่ hosting) · monitoring: Sentry + uptime/heartbeat + PostHog ติดตั้งตั้งแต่ Phase B วันแรก

§12ส่วนต่างระหว่างโค้ดจริงกับเอกสาร (audit ส.ค. 2026)

12.1 จุดที่เอกสารเดิมล้าสมัย — แก้แล้วในเล่มนี้ตอนรวมไฟล์

  • สวิตช์ adapter: tech-plan เดิมเรียก VITE_ADAPTER → ชื่อจริงคือ NEXT_PUBLIC_ADAPTER (แอปเป็น Next ไม่ใช่ Vite)
  • sketch ApiClient แบบย่อใน tech-plan เดิม → ยึดนิยามจริงจาก ports.ts (§7) ซึ่งมีเมธอดครบกว่า
  • E2E "5 journeys" ตามแผน → ทำจริง 6 (แยก dup dialog เป็น 2 + เพิ่มจบนัด/เช็คชื่อ)
  • mock "หน่วง 200–500ms + จำลอง error ได้" → ของจริงหน่วงคงที่ 250ms และยังไม่มีโหมดจำลอง error (ช่องว่างจริง — ดู 12.2)

12.2 ช่องว่างที่โค้ดยังไม่ตามแผน/สเปก (งานค้างจริง)

#เรื่องสถานะ
1ไลบรารี QR (promptpay-qr + qrcode)ยังไม่ติดตั้ง — บล็อก QR ในจอ recap เป็น placeholder (งานก่อน/ตอน Phase B)
2@line/liff SDKยังไม่ติดตั้ง (Phase B)
3โหมดจำลอง error ใน mockยังไม่มี — จอไม่เคยถูกทดสอบ error state (ควรทำก่อนเขียน HttpApiClient)
4จอ L3 สถิติรายปี gate Proยังโชว์เต็มทุกคน มีแค่ banner — prd.md §6.4 กำหนดฟรีเห็น teaser
5Deploy (A8 ข้อสุดท้าย)ค้าง — รอบัญชี Vercel/Cloudflare ของเจ้าของ
6apps/worker, packages/flexยังไม่สร้าง (ตามแผน Phase B — ไม่ใช่ของหาย)

12.3 เอกสารที่ไม่ได้รวมเข้าเล่มนี้ (prd.md) ที่ยังล้ากว่าโค้ด

  • prd §7: ระบุ ETA ผ่าน OSRM/Longdo และแผนที่ "Google Maps / Mapbox" → มติจริงคือ Leaflet + OSM (§15) และ ETA ปัจจุบันมาจาก mock ล้วน — วิธีคำนวณจริงยังไม่ถูกเลือก (งาน Phase B)
  • prd §10: ตัวเลขบิล infra ที่ T2 ต่ำกว่าประมาณการล่าสุด — ดูข้อสังเกตท้าย §25

§13ความเสี่ยง / หนี้เทคนิคที่บันทึกแล้ว

  • issue 001 — draft หายเมื่อ LIFF reload → ปิดแล้ว (4d660c1): persist ลง sessionStorage + E2E
  • issue 002 — back ย้อนเข้า flow ที่จบแล้ว → ปิดแล้ว (3630079): replace 5 จุด
  • issue 003 🟡→🔴 — entry hardcode g-demo + URL ไม่ใช่สิทธิ์ · บล็อกการปล่อย Phase B: ต้อง resolve กลุ่มจาก LIFF context + verify ID token + เช็ก membership ทุก endpoint (403)
  • ไม่มี error/loading discipline ฝั่งจอ (ผลจาก mock ที่ไม่เคยล้ม — §12.2 ข้อ 3) — ควรเติมโหมด error ใน mock ก่อนเขียน HttpApiClient เพื่อไล่เก็บ error state ทุกจอ
  • in-app browser ของ LINE ทดสอบจริงได้ตอน Phase B เท่านั้น — กันเวลา 1–2 วันท้าย B6

รายละเอียด issue อยู่ที่ docs/issue/ (แยกโฟลเดอร์ open / done / closed ตามสถานะ)

§14บันทึกการตัดสินใจสำคัญ (ADR ย่อ)

การตัดสินใจเหตุผลหลักอ้างอิง
Next.js แบบ SPA-first (ไม่ใช้ SSR)LIFF ไม่ต้องการ SEO · Phase B ได้ REST+webhook ในโปรเจกต์เดียว§15
Ports & Adapters + mock-firstเปิดเดโมได้โดยไม่มี LINE/backend · สลับเป็นของจริงโดยไม่แก้จอ§2, §7
Business logic รวมศูนย์ที่ sharedเขียนครั้งเดียว เทสต์ครั้งเดียว ใช้ทั้ง mock/backend§2, §6
Zustand เฉพาะ draft + toastโครงเล็กตรงกับ prototype ไม่ over-engineer§5.3, §15
Leaflet + OSM (ห้าม Google Maps)ต้นทุน tiles แทบไม่โตตามผู้ใช้ — ย้ายแหล่ง tiles = แก้ URL จุดเดียว§23.5
Polling 20 วิ (ไม่ทำ websocket)พอกับ use case และตรงข้อจำกัด LIFF (foreground-only)§19
ตัด user upload ทั้งหมดจาก MVPเลี่ยง storage/moderation/PDPA ทั้งชุด — QR เจนจากเบอร์เหนือกว่ารูปอัปโหลด§15, prd §6.1
Derive แทนการเก็บค่าคำนวณค่าที่เก็บซ้ำคือแหล่งกำเนิดบั๊ก sync§20
ค่าปรับ = ฟรี, ตัวเก็บเงิน = สถิติ/ความยืดหยุ่นโมเมนต์ viral ต้องอยู่ฝั่งฟรีprd §6.4
Scheduler อยู่นอก serverlessเตือนนัดต้องแม่นระดับนาที — Vercel Cron ฟรีทำได้แค่รายวัน§15, §23.3
ส่วน B

แผนงาน + สัญญา API + Schema

เดิม: tech-plan.md

§15Stack

15.1 Phase A — LIFF App (ใช้จริงแล้ว)

ส่วนเลือกใช้เหตุผล
FrameworkNext.js 15 (App Router) + TypeScript — SPA-firstแอป LIFF ไม่ต้องการ SSR/SEO จึงเขียนเป็น client components เกือบทั้งหมด — เหตุผลที่เลือก Next: Phase B ใช้ Route Handlers เป็น REST API + LINE webhook ในโปรเจกต์เดียว ลดของที่ต้อง deploy
RoutingApp Router — จอ 13 จอ ใช้ route กลุ่มเดียว /g/[groupId]/...ตรงกับ prev[] ของ prototype
StateZustand (เฉพาะ draft + toast)เบา, โครงตรงกับ state เดียวของ prototype, ไม่ over-engineer
StylingTailwind CSS v4 + design tokens ใน @theme (§33)inline style ของ prototype แปลงเป็น utility ได้ตรงๆ
แผนที่Leaflet + OpenStreetMap (ฟรี)ห้ามใช้ Google Maps ช่วงแรก (ต้นทุน) · แผนย้าย tiles ตามสเกล: OSM สาธารณะ → free tier MapTiler/Stadia/Longdo → self-host PMTiles บน Cloudflare R2 (ไฟล์ไทย ~1–2GB, egress ฟรี) — Leaflet ทำให้สลับแหล่ง tiles ได้โดยแก้ URL จุดเดียว
วันเวลาIntl.DateTimeFormat (Asia/Bangkok + พ.ศ.) ใน lib/format.tsทุกจอใช้ พ.ศ. — model เก็บ UTC เสมอ
QRpromptpay-qr + qrcodeยังไม่ติดตั้ง (§12.2)มาตรฐานเปิด เจนในเครื่อง 0฿ · ดีไซน์ 2 ชั้น: การ์ด Flex กลางกลุ่ม = QR ไม่ฝังยอด / หน้า LIFF ส่วนตัว = QR ฝังยอดเฉพาะคน · แสดงเบอร์แบบ mask (081-xxx-5678) · UX สแกน: QR ไม่ใช่ลิงก์ ผู้จ่ายเห็นบนเครื่องตัวเอง → ต้องมี hint "เซฟรูปแล้วเปิดแอปธนาคาร เลือกสแกนจากอัลบั้ม" (ทุกแอปธนาคารไทยรองรับ)
ฟอนต์Noto Sans Thai ผ่าน next/font (self-host อัตโนมัติ)ตรง token, ไม่พึ่ง CDN ตอน deploy จริง
DeployVercel free tier หรือ Cloudflare Pagesได้ลิงก์จริงให้คนกดเล่นบนมือถือทันที

15.2 Phase B — Backend + Bot (วางไว้ก่อน ยังไม่ทำ)

ส่วนเลือกใช้
REST API + LINE webhookNext.js Route Handlers ในแอปเดียวกัน (app/api/...) — ไม่ต้องมี server แยก
LINE@line/bot-sdk (push/reply + Flex) · @line/liff (ฝั่ง front)
DBPostgres (Supabase free tier) + Drizzle ORM
Schedulerต้องอยู่นอก serverless: worker เล็กบน Railway/Fly อ่านตาราง scheduled_jobs ทุกนาที (หรือ Upstash QStash) — Vercel Cron ฟรีทำได้แค่รายวัน ไม่พอ
PaymentStripe (PromptPay) ตาม prd.md §8.3
รูปภาพ/ไฟล์ไม่มี user upload เลยใน MVP: QR สร้างสดจากเบอร์ PromptPay · avatar hotlink จาก LINE CDN · Flex ต้องการรูปเป็น URL → API route /api/qr/[eventId].png สร้าง on-the-fly · backlog: อัปโหลด QR อาจกลับมาเป็นฟีเจอร์ Pro
HostingVercel (Next ทั้ง front+API) + worker บน Railway + Cloudflare DNS

§16API Contract — REST endpoints (Phase B ต้อง implement ตามนี้)

Interface ฝั่ง client และ entities: ยึด packages/shared/src/ports.ts + types.ts เป็น source of truth (สรุปใน §6–7) — mock กับ backend จริงต้องใช้สัญญาร่วมกันชุดนี้

GET  /groups/:id                         GET  /groups/:id/events?filter=upcoming|past&q=
POST /events            → 201 | 409 {existingEvent}   # atomic + dup-check (กลุ่มเดียว วันเดียวกัน)
GET  /events/:id        PATCH /events/:id             DELETE /events/:id   # cancel: คืนโควตา+ลบพิกัด
POST /events/:id/rsvp   {status}
POST /events/:id/checkin {lat,lng}       POST /events/:id/arrive
POST /events/:id/end    {attendance:[{userId,present}]}   # หัวตี้เช็คชื่อแล้วจบนัด → สร้าง recap + ลบพิกัด
GET  /events/:id/live                    # polling ทุก 20 วิ (พอสำหรับ use case — อย่าเพิ่งทำ websocket)
GET  /events/:id/recap                   GET  /groups/:id/stats?year=2569
GET  /groups/:id/subscription            POST /billing/checkout {plan}

กติกา: ทุก endpoint idempotent เท่าที่ทำได้ (กด RSVP ซ้ำ = ผลเดิม) · unique constraint กันนัดซ้อน/กดแย่ง ตาม prd.md §6.1 · ทุก endpoint ต้อง verify LIFF ID token + เช็ก membership ก่อนตอบ (issue 003 — บล็อกการปล่อย Phase B)

§17แผนการเทสต์ (เขียนไปพร้อมโค้ด ไม่ใช่ท้ายโปรเจกต์)

ชั้นเครื่องมือเทสต์อะไร (สำคัญสุดก่อน)สถานะ
Unit (packages/shared)Vitest🎯 fine calculator: ผ่อนผัน 5 นาที, รายนาที/เหมา, เพดาน, clamp สิทธิ์ฟรี · quota: 3/เดือน, คืนเมื่อยกเลิก, รีเซ็ตต้นเดือน · dup-check · shareLead · eventPhase (ใช้ Clock ปลอม)ทำแล้ว
ComponentVitest + Testing LibraryRSVP 3 สถานะ + dialog ยืนยัน · gating ฟรี→paywall · ฟอร์ม 2 ขั้น validationยังไม่ทำ
E2EPlaywright (viewport 375×812)15 เคส: 6 journeys หลัก + draft persist + responsive + วิธีรับเงิน + place/แผนที่15/15 เขียว
Visual (เบาๆ)Playwright screenshotจอสำคัญ 4 จอ (L1, L1-i, L2, L6) กัน CSS พังโดยไม่รู้ตัวทำแล้ว
Phase B เพิ่มVitest + supertestwebhook: verify LINE signature, wake word parsing, reply/push routing · Flex JSON: validate โครงสร้าง + ยิงเข้ากลุ่มทดสอบจริง

Definition of Done ของ Phase A: E2E เขียว + unit coverage ของ packages/shared ≥ 90% + เปิดจากมือถือจริงผ่านลิงก์ deploy ได้

§18ลำดับงาน (Milestones)

Phase A

#งานได้อะไร
A1 ตั้ง monorepo + tokens + ports/interfaces + MockApiClient + data model + CLAUDE.md + CI (vitest)โครงที่ "พร้อมต่อ" ตั้งแต่วันแรก
A2 packages/shared: fine calculator + quota + dup-check พร้อม unit tests ครบlogic ธุรกิจนิ่งก่อนมีจอ
A3 จอ L1 (3 โหมด) + L1-i + RSVP + dialogsเส้นทางหลักเดินได้
A4 ฟอร์ม L1-c + L1-b + time picker + L1-p + dup dialogสร้างนัดจริงลง mock DB
A5 L2 แผนที่ (Leaflet) + consent + สถานะ idle/moving/arrived + GeoService mockจอขายหลักของโปรดักต์
A6 L6 recap + L5 ประวัติ+ค้นหา + L3 สถิติ 3 ปี + L4 paywall + Pro gatingครบ loop คุณค่า + เงิน
A7 O1–O3 onboarding + toast ทุกจุด + empty/error statesความละเอียดที่ทำให้ "เอ็นดู"
A8 ค้างE2E journeys + visual snapshots + deployE2E 15 เคส + visual 4 จอ เขียวครบ — เหลือ deploy อย่างเดียว (บัญชี Vercel/Cloudflare ของเจ้าของ)

Phase B (โครงไว้)

B1 ตั้ง LINE OA/LIFF/provider → B2 backend + DB + HttpApiClient (ผ่าน contract §16) → B3 webhook + wake word + Flex F0–F8 → B4 scheduler Push #0–#4 + จบนัด T+2 → B5 Stripe → B6 เทสต์ in-app browser จริง + seed 20–30 กลุ่ม

§19ความเสี่ยง / กติกากันพัง

  • อย่าเพิ่ม scope จอใหม่ใน Phase A — prototype คือเส้นขอบเขต ถ้าคิดฟีเจอร์ใหม่ให้จดใส่ backlog ไม่ทำเลย
  • in-app browser ของ LINE เทสต์ได้จริงตอน Phase B เท่านั้น — กันเวลา 1–2 วันท้าย B6 เสมอ
  • เวลา/เขตเวลา: เก็บ UTC ใน model, แสดง พ.ศ.+เวลาไทยที่ชั้น UI เท่านั้น (บั๊กคลาสสิกของแอปนัดหมาย)
  • พิกัดใน mock ก็ต้องเดินผ่าน "ลบเมื่อจบนัด" จริงๆ — พฤติกรรม privacy ต้องถูกเทสต์ตั้งแต่ mock ไม่ใช่ค่อยเติมทีหลัง
  • อย่าทำ websocket/realtime หรู — polling 20 วิพอ และตรงกับข้อจำกัด LIFF (foreground-only) อยู่แล้ว
  • Admin dashboard: ไม่สร้างใน Phase B วันแรก — ใช้ชุดสำเร็จรูปประกอบกัน (LINE OA Manager = โควตา push/insights · Supabase dashboard = ดู/แก้ข้อมูล · Stripe dashboard = เงิน/คืน Pro · Sentry/PostHog/logs ตาม §24) · ทริกเกอร์ที่ต้องสร้าง /admin ใน Next แอปเดิม: งาน support ซ้ำเกิน ~20 ครั้ง หรือมีพนักงานคนแรก (ห้ามให้ SQL ตรง) หรือคำขอลบข้อมูล PDPA เริ่มเข้า (ระหว่างนี้ต้องมี SQL runbook ลบผู้ใช้ครบทุกตารางเขียนไว้เป็นลายลักษณ์อักษร) · เนื้อหา minimal: ค้นกลุ่ม/ผู้ใช้, timeline นัด, comp/คืน Pro, ปุ่มลบ PDPA, กราฟโควตา push — คนละตัวกับ dashboard organizer B2B (ฟีเจอร์ขายเงิน เฟส 2 ใน PRD)

§20Schema (mock ใน Phase A · Drizzle/Postgres ใน Phase B)

20.1 ER Diagram

erDiagram
  users ||--o{ group_members : "อยู่ในกลุ่ม"
  groups ||--o{ group_members : "มีสมาชิก"
  groups ||--o{ events : "มีนัด"
  groups ||--o| subscriptions : "Pro (1:1)"
  users ||--o| location_consents : "ยินยอมแชร์ (1:1)"
  users ||--o{ events : "เป็นหัวตี้"
  events ||--o{ rsvps : "ตอบรับ"
  users ||--o{ rsvps : ""
  events ||--o{ location_pings : "พิกัดชั่วคราว"
  users ||--o{ location_pings : ""
  events ||--o{ arrivals : "เวลาถึงจริง"
  users ||--o{ arrivals : ""
  events ||--o{ scheduled_jobs : "Push 0-4 + T+2h"

  users {
    text line_user_id PK
    text display_name
    text picture_url
    boolean is_oa_friend "แอด OA = เตือน 1:1 ได้ (Pro)"
  }
  groups {
    text line_group_id PK
    text name "null ได้ - UI มี fallback"
    int member_count "จาก Member Count API"
  }
  group_members {
    text group_id PK,FK
    text user_id PK,FK
    timestamptz first_seen_at "รู้จักตอนโต้ตอบครั้งแรก"
  }
  events {
    uuid id PK
    text group_id FK
    text created_by FK "หัวตี้"
    text title
    timestamptz starts_at "UTC เสมอ"
    int share_lead_min "หน้าต่างติดตาม: ฟรี 60 / Pro ถึง 10080"
    int remind_min "30-60-180"
    enum status "upcoming-active-ended-cancelled"
    enum end_reason "host-all_arrived-timeout-cancelled"
    boolean fine_enabled "กติกาค่าปรับฝังในนัด (1:1)"
    enum fine_kind "per_min หรือ flat"
    int fine_rate "ฟรี clamp: 1 หรือ 50"
    int fine_cap "null = ไม่จำกัด (Pro)"
    int grace_min "ผ่อนผัน 5 นาทีเสมอ"
    text payee_name "คนถือกองกลาง - พิมพ์เอง"
    text payee_promptpay "null = โอนกันเองนอกระบบ"
  }
  rsvps {
    uuid event_id PK,FK
    text user_id PK,FK
    enum status "join หรือ decline (UPSERT = idempotent)"
  }
  location_consents {
    text user_id PK,FK
    timestamptz granted_at
    timestamptz revoked_at
  }
  location_pings {
    bigserial id PK
    uuid event_id FK
    text user_id FK
    double lat "TTL: ลบทั้งหมดเมื่อนัดจบ (PDPA)"
    double lng
    int eta_min
  }
  arrivals {
    uuid event_id PK,FK
    text user_id PK,FK
    timestamptz arrived_at "สิ่งเดียวที่เหลือหลังลบพิกัด"
    int late_min "หลังหักผ่อนผัน"
    int fine_amount "จาก shared calcFine"
    boolean self_reported "กดถึงเอง (ไม่มี GPS) / หัวตี้เช็คชื่อให้"
  }
  scheduled_jobs {
    uuid id PK
    uuid event_id FK
    enum kind "push0-4 หรือ end_timeout"
    timestamptz run_at "UNIQUE(event,kind) - เลื่อนนัด = UPDATE"
    timestamptz done_at
  }
  subscriptions {
    text group_id PK,FK
    enum plan "monthly หรือ yearly"
    text status "ตาม Stripe"
    timestamptz current_period_end
  }

ค่าคำนวณที่จงใจไม่มีตาราง (derive เสมอ กัน desync): โควตาเดือน (นับนัดไม่ถูกยกเลิกในเดือน), no-show (join ที่ไม่มีแถว arrivals), สถานะ live (จาก pings+arrivals), สถิติรายปี/รางวัล (aggregate จาก arrivals)

20.2 ตาราง (SQL — Phase B แปลงเป็น Drizzle ตรงๆ)

-- ผู้ใช้: cache โปรไฟล์จาก LINE (refresh เป็นระยะ — ชื่อ/รูปเปลี่ยนได้)
CREATE TABLE users (
  line_user_id  text PRIMARY KEY,
  display_name  text NOT NULL,
  picture_url   text,
  is_oa_friend  boolean NOT NULL DEFAULT false,   -- แอด OA แล้ว = เตือน 1:1 ได้ (ฟีเจอร์ Pro)
  updated_at    timestamptz NOT NULL DEFAULT now()
);

-- กลุ่ม LINE: รู้จักตอนบอทถูกดึงเข้า (Group Summary API ให้ชื่อ/รูป, Member Count ให้จำนวน)
CREATE TABLE groups (
  line_group_id text PRIMARY KEY,
  name          text,                              -- null ได้ (กลุ่มไม่ตั้งชื่อ) → UI ใช้ fallback
  picture_url   text,
  member_count  int,
  created_at    timestamptz NOT NULL DEFAULT now()
);

-- สมาชิกที่ "บอทรู้จักแล้ว" — เพิ่มทีละคนตอนเขาพิมพ์/กดปุ่มครั้งแรก (ดึงรายชื่อล่วงหน้าไม่ได้)
CREATE TABLE group_members (
  group_id      text REFERENCES groups,
  user_id       text REFERENCES users,
  first_seen_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (group_id, user_id)
);

CREATE TYPE event_status AS ENUM ('upcoming','active','ended','cancelled');
CREATE TYPE end_reason   AS ENUM ('host','all_arrived','timeout','cancelled');
CREATE TYPE fine_kind    AS ENUM ('per_min','flat');

CREATE TABLE events (
  id             uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  group_id       text NOT NULL REFERENCES groups,
  title          text NOT NULL,
  note           text,                             -- "รายละเอียดจากผู้สร้าง" บนการ์ด
  place_name     text NOT NULL,
  place_sub      text,
  place_lat      double precision,
  place_lng      double precision,
  starts_at      timestamptz NOT NULL,             -- เก็บ UTC เสมอ — แสดง พ.ศ./เวลาไทยที่ UI
  share_lead_min int  NOT NULL DEFAULT 60,         -- ฟรี 60 · Pro สูงสุด 10080 (7 วัน)
  remind_min     int  NOT NULL DEFAULT 60,         -- 30/60/180 (ปรับในจอแก้ไขนัด)
  status         event_status NOT NULL DEFAULT 'upcoming',
  end_reason     end_reason,
  ended_at       timestamptz,
  created_by     text NOT NULL REFERENCES users,
  created_at     timestamptz NOT NULL DEFAULT now(),

  -- กติกาค่าปรับ (1:1 กับนัด — ฝังเป็นคอลัมน์ ไม่แยกตาราง)
  fine_enabled   boolean NOT NULL DEFAULT true,
  fine_kind      fine_kind NOT NULL DEFAULT 'per_min',
  fine_rate      int NOT NULL DEFAULT 1,           -- ฟรี clamp: per_min=1 / flat=50 (บังคับซ้ำที่ shared logic)
  fine_cap       int DEFAULT 50,                   -- null = ไม่จำกัด (Pro) · ใช้กับ per_min เท่านั้น
  grace_min      int NOT NULL DEFAULT 5,           -- ผ่อนผัน 5 นาทีแรกเสมอ
  payee_name     text,                             -- คนถือกองกลาง — เป็นใครก็ได้
  payee_promptpay text                             -- เบอร์/เลข PromptPay → สร้าง QR + ยอดอัตโนมัติ
);
-- กันนัดซ้อน (ชั้น server): นัด active/upcoming ของกลุ่มเดียวกัน วันเดียวกัน → สร้างซ้ำต้องยืนยัน (409)
CREATE INDEX idx_events_dup ON events (group_id, (starts_at::date)) WHERE status IN ('upcoming','active');
-- โควตาฟรี = COUNT(events WHERE group_id=? AND status<>'cancelled' AND created_at ในเดือนนี้)

CREATE TYPE rsvp_status AS ENUM ('join','decline');
CREATE TABLE rsvps (
  event_id   uuid REFERENCES events ON DELETE CASCADE,
  user_id    text REFERENCES users,
  status     rsvp_status NOT NULL,
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, user_id)                  -- กดซ้ำ/เปลี่ยนใจ = UPSERT แถวเดิม (idempotent)
);

-- ยินยอมแชร์ตำแหน่ง (ครั้งแรกครั้งเดียว จำไว้ระดับ user)
CREATE TABLE location_consents (
  user_id    text PRIMARY KEY REFERENCES users,
  granted_at timestamptz NOT NULL DEFAULT now(),
  revoked_at timestamptz
);

-- พิกัดระหว่างเดินทาง — TTL ตายตัว: ลบทุกแถวของ event ทันทีที่ status → ended/cancelled (PDPA = จุดขาย)
CREATE TABLE location_pings (
  id         bigserial PRIMARY KEY,
  event_id   uuid NOT NULL REFERENCES events ON DELETE CASCADE,
  user_id    text NOT NULL REFERENCES users,
  lat        double precision NOT NULL,
  lng        double precision NOT NULL,
  eta_min    int,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_pings_latest ON location_pings (event_id, user_id, created_at DESC);

-- เวลาถึงจริง — เก็บถาวร (สิ่งเดียวที่เหลือหลังลบพิกัด) → recap + สถิติรายปี + รางวัล
CREATE TABLE arrivals (
  event_id    uuid REFERENCES events ON DELETE CASCADE,
  user_id     text REFERENCES users,
  arrived_at  timestamptz NOT NULL,
  late_min    int NOT NULL DEFAULT 0,              -- หลังหักผ่อนผันแล้ว
  fine_amount int NOT NULL DEFAULT 0,              -- คำนวณจาก shared fine calculator ตอนบันทึก
  self_reported boolean NOT NULL DEFAULT false,    -- true = กด "ถึงแล้ว" เองแบบไม่แชร์พิกัด (เคส O3)
  PRIMARY KEY (event_id, user_id)
);
-- no-show = คน rsvp:join ที่ไม่มีแถวใน arrivals ตอนนัดจบ — ไม่คิดเงิน แต่โผล่ในสถิติ

CREATE TYPE job_kind AS ENUM ('push0','push1','push2','push3','end_timeout');
CREATE TABLE scheduled_jobs (
  id       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  event_id uuid NOT NULL REFERENCES events ON DELETE CASCADE,
  kind     job_kind NOT NULL,
  run_at   timestamptz NOT NULL,
  done_at  timestamptz,                            -- worker หยิบงานที่ run_at<=now() AND done_at IS NULL
  UNIQUE (event_id, kind)                          -- เลื่อนนัด = UPDATE run_at ไม่ใช่สร้างซ้ำ
);
CREATE INDEX idx_jobs_due ON scheduled_jobs (run_at) WHERE done_at IS NULL;

CREATE TYPE plan_kind AS ENUM ('monthly','yearly');
CREATE TABLE subscriptions (
  group_id           text PRIMARY KEY REFERENCES groups,
  plan               plan_kind NOT NULL,
  status             text NOT NULL DEFAULT 'active',   -- active|past_due|canceled (ตาม Stripe)
  current_period_end timestamptz NOT NULL,             -- pro = status active AND period_end > now()
  stripe_customer_id text,
  stripe_sub_id      text
);

20.3 TypeScript types

นิยามจริงที่ใช้แล้วอยู่ packages/shared/src/types.ts (สรุปใน §6) — MockApiClient และ backend Phase B ใช้รูปทรงเดียวกันนี้ ไม่มีร่างแยกอีกชุดเพื่อกัน desync

20.4 กติกาที่ schema บังคับให้เป็นจริง

  1. Privacy โดยโครงสร้างlocation_pings ผูก TTL กับสถานะนัด (ลบเมื่อ ended/cancelled) ส่วน arrivals คือสิ่งเดียวที่เหลือ ตรงกับ copy ในจอ O2: "ลบพิกัดหลังนัดจบ เก็บไว้แค่เวลาที่มาถึง"
  2. โควตา/no-show ไม่มีวัน desync — เพราะไม่ได้เก็บ แต่คำนวณจากข้อมูลจริงเสมอ
  3. Idempotent ตั้งแต่ DB — PK ซ้อน (event_id, user_id) ทำให้กด RSVP/เช็กอินรัวๆ ได้ผลเดิม, UNIQUE(event_id, kind) ทำให้เลื่อนนัดไม่ทำให้เตือนซ้ำสองรอบ
  4. สิทธิ์ฟรี clamp สองชั้น — UI ชั้นหนึ่ง (ตาม prototype) และ shared fine calculator ตรวจซ้ำก่อนบันทึกเสมอ (อย่าเชื่อ client)

§21Agents / เครื่องมือ Claude Code ที่ใช้

ตัวใช้ตอนไหน
CLAUDE.md ของ repoกติกา: ห้าม hardcode ข้อมูลในจอ, ทุกจออ่านผ่าน ports, token สีจาก §33, ตัวเลข tabular-nums, โทน copy "ค้าบ/ครับ"
Explore agentค้นสเปกจาก prototype/§31 ตอน implement แต่ละจอ โดยไม่เปลืองบริบทหลัก
Plan agentก่อนงานใหญ่แต่ละก้อน (ตั้งโครง, แผนที่ L2, ระบบ Pro gating)
screen-builder (.claude/agents/)งานซ้ำรายจอ: รับชื่อจอ → อ่านสเปกจาก dc.html + §31 → สร้าง component React ตาม token + mock
spec-checkerหลังแต่ละจอเสร็จ: เทียบ implementation กับ §31 (สี/ขนาด/state ครบไหม) รายงานส่วนต่าง
qa-gateก่อน commit ทุก milestone: typecheck/test/build + ตรวจ diff เทียบกติกา CLAUDE.md
/code-reviewก่อนปิดแต่ละ milestone
/security-reviewก่อน deploy จริงครั้งแรก + ทุกครั้งที่แตะเรื่องพิกัด/เงิน (PDPA: พิกัดต้องมี TTL/ลบได้จริง แม้ใน mock)
Playwright ผ่าน Bashรัน E2E ใน loop พัฒนา
ส่วน C

Infra & ราคา

เดิม: infra-stack.md · อัตราแลกเปลี่ยนสมมติ 36฿/USD · ราคา ณ ส.ค. 2026 โดยประมาณ ตรวจซ้ำก่อนผูกบัตรเสมอ

§22ระดับผู้ใช้ที่ใช้ทั้งส่วนนี้ (อิง Base Case ใน prd.md §11)

Tierช่วงเวลากลุ่ม activeผู้ใช้ (~6 คน/กลุ่ม)อีเวนต์/เดือนโหลดสำคัญ/เดือน
T0 Seedเดือน 1–250~300~150push 600 · แผนที่ ~1.5k ครั้ง · poll ~45k req
T1 Launchเดือน 3–61,200~7,000~3,600push 14k · แผนที่ ~36k · poll ~1.1M req
T2 Growthเดือน 7–125,600~34,000~16,000push 64k · แผนที่ ~160k · poll ~4.8M req
T3 Year-2ปีที่ 2 (optimistic)20,000~120,000~60,000push 240k · แผนที่ ~600k · poll ~18M req

สูตรโหลด: push 4/อีเวนต์ · เปิดแผนที่ ~10 ครั้ง/อีเวนต์ · poll 20 วิ × เฉลี่ยเปิดหน้าละ 10 นาที = ~30 req/การเปิด

§23ตารางหลัก: ของที่เลือก vs ทางเลือก vs ราคาตาม Tier

23.1 Frontend + API (LIFF app + REST + LINE webhook)

เลือก: Vercel (Next.js รวม front+API)ทางเลือก A: Cloudflare Pages+Workersทางเลือก B: VPS (DigitalOcean)
ความสามารถNext.js native, Route Handlers เป็น API/webhook ในโปรเจกต์เดียว, preview deploy ทุก commit, CDN+SSL อัตโนมัติถูกมาก, ไม่มี cold start, แต่ Next บน Workers มีข้อจำกัด (edge runtime)ควบคุมเต็มที่ ราคาคงที่ แต่ดูแลเอง (patch, SSL, scale)
เหตุผลชนะ: ของทั้งระบบอยู่ที่เดียว, DX เร็วสุดสำหรับ solo devสำรองไว้ถ้าค่า Vercel บาน — ย้ายได้เพราะไม่ผูก feature เฉพาะแพ้: เวลา ops ของ solo dev แพงกว่าค่า hosting
T0 / T10฿ (Hobby — T1 จุดเฝ้าระวัง: 100GB bandwidth/1M invocations)0฿~220฿
T2~720฿ (Pro $20)~180฿ (Workers Paid $5)~440฿
T3~720–1,500฿~360฿~880฿

23.2 Database (Postgres)

เลือก: Supabaseทางเลือก A: Neonทางเลือก B: Railway Postgres
ความสามารถPostgres เต็มตัว + connection pooler ในตัว + Auth/Storage เผื่ออนาคต + dashboard ดูข้อมูลง่ายserverless PG, branch ได้เหมือน git, scale-to-zeroง่าย ราคา usage-based, อยู่ที่เดียวกับ worker
เหตุผลชนะ: pooler ฟรีในตัว (จำเป็นกับ serverless) + เครื่องมือครบใน dashboard เดียวดีมากเรื่อง dev branch — แพ้ที่ไม่มีของแถมอื่นแพ้: ไม่มี pooler ในตัว
T0 / T10฿ (Free: 500MB DB — ข้อมูลเรานิดเดียว เพราะพิกัดถูกลบตลอด)0฿~180฿
T2~900฿ (Pro $25 — backup 7 วัน + ไม่ pause)~700฿~360฿
T3~900–1,800฿~1,400฿~720฿

23.3 Worker / Scheduler (Push #0–#4 + จบนัด T+2 ชม.)

เลือก: Railway worker เล็กทางเลือก A: Upstash QStashทางเลือก B: Vercel Cron
ความสามารถโปรเซส Node เล็กอ่าน scheduled_jobs ทุกนาที — ยิงได้ทุกเวลา ยืดหยุ่นสุดยิง HTTP ตามเวลา per-message, ไม่ต้องมี serverฟรี แต่แผน Hobby จำกัดรายวัน — ไม่พอ (เตือนก่อนนัดต้องแม่นระดับนาที)
เหตุผลชนะ: logic จบนัด/เช็กเงื่อนไขซับซ้อนกว่าแค่ "ยิงตามเวลา" — ต้องอ่าน DB ตัดสินใจดีถ้าอยากไร้ server แต่ debug ยากกว่าตัดทิ้ง
T0–T1 / T2 / T3~180฿ / ~180–360฿ / ~360฿~0–100฿ / ~360฿ / ~720฿0฿ / — / —

23.4 LINE Platform (ตัวเลขจริงที่ตรวจแล้ว — ต้นทุนก้อนใหญ่สุดของระบบ)

Tierpush/เดือนแพ็กเกจที่ต้องใช้ราคา (รวม VAT)
T0600Basic 15,000 msg~1,370฿
T114,400Basic 15,000 msg (เฉียดเพดาน — เฝ้าระวัง)~1,370฿
T264,000Pro 35,000 + ส่วนเกิน 29,000×0.06฿~3,645฿
T3240,000Pro + ส่วนเกิน 205,000×0.06฿~14,200฿

เหตุผลไม่มีทางเลือก: ผูกกับ platform โดยธรรมชาติ — คันโยกลดต้นทุนคือสถาปัตยกรรม reply-first/piggyback (prd §7) ไม่ใช่การเปลี่ยนผู้ให้บริการ · LIFF/webhook/Group API ฟรีหมด

23.5 แผนที่ (tiles) — แผนย้ายตามสเกล

Tiertiles/เดือน (~800/อีเวนต์)เลือกใช้ราคาทางเลือกที่แพ้
T0~120kOSM สาธารณะ (fair-use)0฿
T1~2.9MMapTiler/Stadia free tier หรือ Longdo (POI ไทยดี)0–หลักร้อย฿Google Maps: เกิน free tier แล้วกระโดดหลักหมื่น — ตัดตั้งแต่แรก
T2–T313M–48MPMTiles self-host บน Cloudflare R2 (ไฟล์ไทย ~2GB, egress ฟรี)~30–70฿Mapbox: ~$5/1k loads → หลักหมื่น/เดือนที่สเกลนี้

จุดชนะเชิงโครงสร้าง: Leaflet ทำให้ย้ายแหล่ง tiles = แก้ URL บรรทัดเดียว → ต้นทุนแผนที่แทบไม่โตตามผู้ใช้ (สอดคล้อง gross margin 97% ที่ขายไว้)

23.6 อื่นๆ ที่ต้นทุนคงศูนย์ทุก Tier

ของเลือกราคาหมายเหตุ
QR PromptPaypromptpay-qr เจนในเครื่อง0฿ ตลอดมาตรฐานเปิด ไม่มี API/ค่าธรรมเนียม
ฟอนต์Noto Sans Thai self-host (next/font)0฿bundle มากับแอป
รูป/ไฟล์ไม่มี user upload ใน MVP · avatar hotlink LINE CDN0฿ตัดอัปโหลด QR แล้ว (มติ ส.ค. 2026)
Payment รับเงิน ProStripe PromptPay1.65% ของรายได้แปรผัน ไม่ใช่ fixed — ตามนโยบาย prd §8.3

§24Monitoring & Observability

หลักการ: solo dev ต้องรู้ว่า "พังแล้ว" ก่อนผู้ใช้ทัก LINE มาบอก — เลือกของ free tier ที่แจ้งเตือนถึงมือถือได้จริง

ชั้นเลือกทำอะไรทางเลือกที่แพ้T0–T1T2T3
Error trackingSentry (Next.js SDK + worker)จับ exception ทั้ง LIFF/API/worker พร้อม stack trace + แจ้งเตือนGlitchTip (เวลา ops), Axiom0฿ (5k errors/ด.)0–950฿~950฿
Uptime + cron sanityBetter Stack หรือ UptimeRobotping /api/health ทุกนาที + heartbeat monitor ของ worker (เงียบเกิน 5 นาที = ตาย → แจ้งทันที — สำคัญสุดเพราะ push เตือนนัดคือหัวใจ)Pingdom (แพง)0฿0–360฿~360฿
LogsVercel logs + Railway logsไล่เหตุการณ์ webhook/APIDatadog (overkill+แพงมาก)0฿0฿0–700฿
DB healthSupabase dashboard + alert email ในตัวconnection/disk/slow querypganalyze (แพง)0฿0฿0฿
Product analyticsPostHog Cloudevent: สร้างนัด, RSVP, เช็กอิน, จบนัด, ชน paywall, จ่าย — วัด K-factor / conversion 4% ที่โมเดลธุรกิจแขวนอยู่GA4 (ไม่เหมาะ), Mixpanel (แพงไว)0฿ (1M events/ด.)0฿0–800฿
LINE quota watchสคริปต์ใน worker เช็กยอด push รายวันเตือนเมื่อถึง 80% ของแพ็กเกจ — กันบิลช็อก (ต้นทุนก้อนใหญ่สุดของเรา)— (ต้องทำเอง)0฿0฿0฿

ลำดับติดตั้ง: Sentry + health check + worker heartbeat = ทำตั้งแต่ Phase B วันแรก (ครึ่งวัน) · PostHog = ก่อน launch จริง (ไม่มีมัน = ไม่รู้ K-factor = ตัดสินใจธุรกิจมืด) · LINE quota watch = ก่อนกลุ่มที่ 500

§25สรุปบิลรวมต่อเดือนตาม Tier (฿)

รายการT0 SeedT1 LaunchT2 GrowthT3 Year-2
Vercel (front+API)00720~1,100
Supabase (DB)00900~1,350
Railway (worker)180180270360
LINE OA1,3701,3703,64514,200
Tiles แผนที่00–200~50~70
Monitoring ทั้งชุด00~950~2,100
Domain เฉลี่ย50505050
รวม~1,600~1,800~6,600~19,200
เทียบรายได้ Base Case (PRD)0~4,400–6,600~11,000–21,000~100,000+
  1. LINE คือ 60–75% ของบิลทุก Tier — ทุกการ optimize ควรทุ่มที่ reply-first/piggyback ก่อนสิ่งอื่นเสมอ
  2. ตัวเลข T2 (~6,600฿) สูงกว่าที่ตาราง prd.md §10 เคยประมาณ (~4,300฿) เพราะรอบนี้รวม monitoring + Vercel/Supabase paid ที่ต้องจ่ายจริงเมื่อโต — ควร sync ตัวเลขนี้กลับเข้า PRD ในรอบถัดไป
  3. ทุก Tier มีกำไรขั้นต้นเหลือ (รายได้ > บิล infra) ตั้งแต่ T1 — โครงสร้างนี้ไม่มีจุดที่ "โตแล้วเจ๊ง"

§26ทริกเกอร์อัปเกรด (จะรู้ได้ไงว่าถึงเวลาจ่าย)

สัญญาณการกระทำ
Vercel bandwidth > 80GB/เดือน หรือ invocations > 800kอัป Pro ($20)
Supabase DB ใกล้ 400MB หรือโดน pause เพราะ idleอัป Pro ($25)
LINE push > 12,000/เดือน ติดกัน 2 เดือนขยับ Basic → Pro (คุ้มที่ ~23k msg)
Tiles โดนผู้ให้บริการเตือน rateถึงเวลา PMTiles + R2 (งานครึ่งวัน)
Sentry error เกิน 5k/เดือนดูก่อนว่า error จริงหรือ noise — แก้บั๊กถูกกว่าอัปแพลน
ส่วน D

Design Brief

เดิม: design-brief.md · ผู้ใช้ไม่เคย "เปิดแอปเรา" — ทุกอย่างเกิดใน 2 พื้นที่: แชตกลุ่ม LINE (Flex/บอท) และหน้า LIFF (375×812)

§27แบรนด์ คาแรกเตอร์ โทน (Brand & Voice)

น้องนัดที (หลัก — MVP)เจ้มายัง (เฟสหลัง)
บุคลิกหนุ่มน้อย+ลูกหมา สุภาพ ขี้อ้อน ให้กำลังใจสาวมั่นสายแซ่บ แซวแรง
สรรพนามผม / ค้าบ / ครับ, เรียกผู้ใช้ "พี่+ชื่อ"เจ้ / จ้า / แม่
ตัวอย่าง"รีบหน่อยนะค้าบ ผมเป็นห่วง 🥺""GPS เจ้ฟ้องว่ายังไม่ได้อาบน้ำเลยมั้งแม่ 😏"
สถานะอยู่ในทุกจอของ prototype (toast, empty state, dialog)ไม่อยู่ใน prototype/MVP — บอทแชทเสริม แอดคู่กันภายหลัง

กติกา copy: น้องนัดทีไม่ประจานตรงๆ · ทุก action สำเร็จมี toast น้ำเสียงเป็นมิตรลงท้าย "ค้าบ/ครับ" · คำแรงเป็นของเจ้มายังเท่านั้น (เฟสหลัง)

§28Surface: อันไหน Flex อันไหน LIFF

ข้อจำกัดที่กำกับการออกแบบ (ยังจริงเสมอ)

  • Flex Message: ประกอบจาก box/text/image/button เท่านั้น — ไม่มี input/animation/custom font · ปุ่ม = เปิด LIFF หรือ postback · Quick Reply สูงสุด 13 ปุ่ม หายเมื่อมีข้อความใหม่
  • LIFF: หน้าเว็บเต็ม 375×812, รู้ตัวตนอัตโนมัติ, ขอพิกัดได้เฉพาะตอนหน้าเปิด (foreground) — จอ O2 เป็นจอ "อธิบายก่อน" permission จริงถูกขอตอนกดอัปเดตตำแหน่งครั้งแรก

ตาราง mapping (ตาม prototype จริง)

ขั้นตอนSurfaceรหัสจอ
บอทแนะนำตัวตอนเข้ากลุ่มFlex + Quick ReplyF1
wake word "น้องนัดที" → ตอบพร้อมปุ่มเข้า LIFFFlex/textF0
เปิด LIFF ครั้งแรก: welcome → อธิบายตำแหน่ง → เคสไม่อนุญาตLIFFO1 → O2 → O3
หน้าแรก: นัดกำลังจะถึง + นัดผ่านมา + โควตา (3 โหมด)LIFF (Full)L1
รายละเอียดนัด + RSVPLIFFL1-i
ฟอร์มสร้างนัด ขั้น 1/2 (+time picker, +ปักหมุด)LIFFL1-c (+dialog, L1-p)
โหมดค่าปรับ + ช่องรับเงิน ขั้น 2/2LIFFL1-b
การ์ดนัดลงกลุ่ม / หลัง RSVPFlexF2 / F2-b
เตือน + เช็กอิน (Push #1–2)FlexF3
คำสั่ง "มายัง" → สถานะสดในแชตFlexF4
ถึงเวลานัด + แท็กคนยังไม่ถึง (Push #3)FlexF5
แผนที่รวม + consent + สถานะตัวเอง 3 แบบLIFF (Full)L2
แก้ไขนัด / เลื่อน / ยกเลิกLIFF + Flex แจ้งกลุ่มL1-r + F8
สรุปจบนัด "หอเกียรติยศ" (Push #4)Flex → ดูเต็มใน LIFFF6 / L6
teaser สถิติ → สถิติรายปีเต็ม (Pro)FlexLIFFF7 / L3
ประวัตินัดกลุ่ม + ค้นหาLIFFL5
Paywall ProLIFFL4

หลักการแบ่ง (ยังใช้ตัดสินฟีเจอร์ใหม่): ทั้งกลุ่มเห็นร่วมกันในแชต = Flex · โต้ตอบซับซ้อน/ข้อมูลรายบุคคล/ฟอร์ม/แผนที่ = LIFF · ชั่วคราวเร่งด่วน = Quick Reply

§29Design decisions ที่ dev ต้องรักษา + เกณฑ์ผ่าน

สิ่งที่ prototype ตัดสินใจแล้ว

  1. Onboarding แบบไม่บังคับ — O3 ทำให้คนไม่ให้ GPS ยังใช้งานได้ (กด "ถึงแล้ว" เอง) ไม่ใช่ dead end
  2. ค่าปรับมี "ผ่อนผัน 5 นาทีแรก" เสมอ + ฟรี clamp ที่ 1฿/นาที เพดาน 50฿ หรือเหมา 50฿
  3. ปุ่มแผนที่ล็อกจนถึง shareLead (ฟรี 1 ชม. / Pro ถึง 7 วัน) — มี countdown บอกชัด
  4. กด "ถึงแล้ว" = หยุดแชร์ทันที — privacy line บนแผนที่เปลี่ยนตามสถานะ
  5. no-show แยกจากคนสาย — ไม่คิดเงิน แต่จดสถิติ (โผล่เป็นรางวัล "แชมป์หายตัว")
  6. ยกเลิกนัด = คืนโควตา + ลบพิกัด + แจ้งกลุ่ม — dialog อธิบายผลทั้ง 3 ข้อก่อนยืนยัน
  7. ทุก action สำเร็จมี toast จากน้องนัดที — คือช่องทางหลักที่คาแรกเตอร์ปรากฏ
  8. Gating ฟรี/Pro = พาไป paywall ทันที ไม่มี alert — ตัวเลือกล็อกเป็นสีจาง + 🔒

งานออกแบบที่เหลือ (ยังไม่อยู่ใน prototype)

  • ภาพจริงการ์ด Flex บน LINE (บอร์ดเป็น mock 300px — ต้องแปลงเป็น Flex JSON แล้วเทสต์ใน LINE จริง โดยเฉพาะ F2/F6 ที่แน่นสุด)
  • Rich Menu ในแชต 1:1 กับ OA (สำหรับคนที่แอดเป็นเพื่อน)
  • หน้า/การ์ดของ "เจ้มายัง" — เฟสหลัง
  • B2B Event mode — เฟส 2
  • Asset จริง: รูปโปรไฟล์ LINE แทน avatar ตัวอักษร (map SDK ทำแล้วด้วย Leaflet)

เกณฑ์ว่า "ผ่าน" (ใช้ตอนเทสต์กับผู้ใช้จริง)

  • คนไม่เคยเห็นแอป ดูการ์ดนัด F2 แล้วตอบได้ใน 5 วินาที: นัดกี่โมง ที่ไหน กดอะไรต่อ
  • ดูแผนที่ L2 แล้วรู้ทันทีว่าใครถึง ใครยังไม่ออก โดยไม่ต้องอ่าน legend
  • การ์ดสรุป F6/L6 สวยพอที่คนอยากแคปไปโพสต์เอง — KPI ของดีไซน์ เพราะคือเครื่องยนต์ viral
  • โทนน้องนัดทีอ่านแล้ว "เอ็นดู" ไม่ใช่ "รำคาญ" (เทสต์กับคนจริง 3–5 คน)
  • คนที่ไม่ให้ GPS ต้องรู้สึกว่า "ยังใช้ได้ปกติ" ไม่ใช่พลเมืองชั้นสอง (เทสต์เส้นทาง O3 → L2 กดถึงแล้วเอง)
ส่วน E

สเปก UX รายจอ

เดิม: README.md (handoff doc)

§30วิธีอ่าน prototype

ไฟล์ design reference ทั้งหมดอยู่ใน demo/:

  • demo/NongNutTee LIFF Prototype.dc.htmlไฟล์หลัก prototype LIFF ทุกจอ กดใช้งานได้จริง (เปิดในเบราว์เซอร์ได้เลย) — source of truth ของพฤติกรรมทุกจอ
  • demo/NongNutTee-Prototype.dc.html — บอร์ด flow ของ Flex Message ในแชตกลุ่ม (การ์ด F0–F8 ที่บอทส่ง)
  • demo/NongNutTee.png — โลโก้/มาสคอต (300×270 ต้นฉบับ)
  • demo/support.js — runtime ของ prototype (ไม่ต้องพอร์ตไป production)

วิธีอ่านไฟล์: template อยู่ใน <x-dc>…</x-dc> (markup + inline style ทั้งหมด), logic อยู่ใน <script data-dc-script> ท้ายไฟล์ (class Component — state, handler, ข้อมูลตัวอย่างทุกจอ), {{ ชื่อ }} ใน markup คือค่าที่มาจาก renderVals()

Fidelity: high-fidelity — สีทุกค่า, ขนาดตัวอักษร, spacing, radius, สถานะ hover ถูกกำหนดไว้จริงในไฟล์ ให้ทำ UI ให้ตรง ข้อยกเว้น: แผนที่ใน prototype วาดจำลองด้วย CSS (ของจริงใช้ Leaflet แล้ว) และ avatar เป็นวงกลมตัวอักษร (ของจริงใช้รูปโปรไฟล์ LINE ใน Phase B)

ลำดับความน่าเชื่อ: prototype เป็น snapshot ณ ส.ค. 2026 — จุดที่ §31 ระบุ "สเปกใหม่...ยึดเหนือ prototype" (จาก issue ภายหลัง เช่น 005/006/007/008/011) ให้ยึด §31 เป็น authority แล้วใช้ prototype เป็น reference โทน/สไตล์

ทุกจอออกแบบบน viewport 375 × 812 (LIFF full-size), พื้นหลังแอป #FAF6EEของจริง render แบบ fluid เต็มจอถึงเพดาน 480px (issue 009: 375 เป็นฐานออกแบบ ไม่ใช่ขนาดล็อก ทดสอบที่ 360/375/430) · prototype ใช้ state screen ตัวเดียวสลับจอ และ prev: string[] เป็น navigation stack (ปุ่ม ← = pop)

§31สเปกรายจอ

O1 welcome — เปิดครั้งแรกหลังเชิญบอทเข้ากลุ่ม
  • พื้น #1D3F7E เต็มจอ, จัดกลางแนวตั้ง · โลโก้ 104×104 radius 26 บนพื้น #FFFDF8
  • eyebrow 11px/900 #F5C33B letter-spacing .14em → หัวเรื่อง 25px/900 #fff line-height 1.35 → คำอธิบาย 13px #BDD0EE line-height 1.7
  • 3 การ์ดบอกความสามารถ: พื้น rgba(255,255,255,.12) radius 14 padding 12/14, emoji 19px + ข้อความ 13px #fff
  • ปุ่มหลัก "เริ่มใช้งาน": พื้น #F5C33B ตัว #1C2740 17px/900 padding 16 radius 16 (hover #E2AF24) → ไป O2
  • บรรทัดท้าย 11px #8FAEDA — ข้อความความเป็นส่วนตัว
O2 permit — ขออนุญาตตำแหน่ง
  • Header ขาว 12/16 + เส้นล่าง #E5E0D2: ← / "อนุญาตตำแหน่ง" 15px/900 / "2 จาก 2" 11px #7C8699
  • การ์ดฟ้า #EEF3FB radius 18: ภาพแผนที่จำลอง 96×78 + หัวเรื่อง 18px/900 + คำอธิบาย 13px #4C5A75
  • 3 การ์ดขาวขอบ #E5E0D2: ⏱️ แชร์เป็นช่วงเวลา / 👥 เห็นแค่คนในนัด / 🗑️ ลบพิกัดหลังนัดจบ (12px line-height 1.6)
  • Footer: "อนุญาตตำแหน่ง" (#2E5FB7, shadow 0 10px 22px -12px rgba(46,95,183,.9)) → list + toast / "ยังไม่อนุญาตตอนนี้" (text 13px #7C8699) → O3
O3 denied — ไม่อนุญาตตำแหน่ง
  • การ์ดส้ม #F7ECD8 ตัวอักษร #B27210: บอกผลกระทบ (เพื่อนไม่เห็นตำแหน่ง ต้องกด "ถึงแล้ว" เอง)
  • การ์ดขาว: ขั้นตอนเปิดทีหลัง 3 ขั้น (badge เลข 22×22 radius 7 #EEF3FB/#2E5FB7)
  • Footer: "ลองอนุญาตอีกครั้ง" (primary) / "ใช้งานต่อโดยไม่แชร์พิกัด" (outline)
L1 list — หน้าแรก 3 โหมด: normal / empty / limit
  • Header ขาว padding 10/14: avatar 36 วงกลม #2E5FB7 + ชื่อกลุ่ม 15px/900 + "พี่มายด์ · 4 คนในกลุ่ม" 11px #7C8699 · โหมด limit เพิ่ม pill "3/3 เดือนนี้" 11px/900 #96700E บน #F7ECD8
  • การ์ดนัดที่กำลังจะถึง (กดทั้งการ์ดไป detail, hover #FCFAF4): พื้นขาว ขอบ #E5E0D2 radius 16 padding 12 — ซ้ายกล่องวันที่ 62×62 radius 14 #EEF3FB (เลขวัน 27px/900 #2E5FB7 + เดือนย่อ 12px/700), กลาง 3 บรรทัด: ชื่อนัด 16px/900 · "ศ. 19:00 · หมูกระทะแสนสิริ" 12px #4C5A75 tabular-nums · บรรทัดสถานะ 11px ("ยังไม่ได้ตอบ" #B27210 / "คุณเข้าร่วมแล้ว" #067A39 / "ไม่เข้าร่วม" #7C8699), ขวา 17px #B9C2D2
  • นัดที่ผ่านมา: แถวรูปแบบเดียวกัน (กล่องวันที่ 52–56px) + บรรทัด "คุณเข้าร่วม" #067A39 → กดไป recap
  • empty: การ์ด dashed ชวนสร้างนัดแรก · limit: การ์ดชนลิมิต + ปุ่มอัป Pro (ปุ่มสร้างนัดถูกซ่อน)
  • แถบโควตา (กดได้ → paywall): พื้น #FDF6E7 ขอบ #F0E4CE radius 12 — "ใช้ไป 2/3 นัดของเดือนนี้" + progress 3 ขีด (สูง 4px, ใช้แล้ว #B27210, ว่าง #EFE3CC) + pill "Pro 👑" #F5C33B
  • Footer: "➕ สร้างนัดใหม่" (primary 17px/900 padding 16 radius 16) + ปุ่มเล็กคู่ "ประวัตินัดกลุ่ม" / "สถิติรายปี" (+pill PRO)
L1-i detail — รายละเอียดนัดที่กำลังจะถึง
  • Header น้ำเงินเข้ม #1D3F7E padding 12/16/16: ←, "รายละเอียดนัด", "แก้ไข" (#BDD0EE) · eyebrow "กำลังจะถึง" #F5C33B · ชื่อนัด 19px/900 · "12 ส.ค. 19:00 · สร้างโดยพี่เจมส์" 12px #BDD0EE · แถบตัวเลข 3 ช่อง rgba(255,255,255,.12) radius 12 (เข้าร่วมแล้ว / เหลืออีก / ค่าปรับสาย — ตัวเลข 19px/900)
  • Body: การ์ดสถานที่ (ภาพแผนที่จำลอง 64×56 · +ปุ่ม "นำทาง" เมื่อนัดมีพิกัด — issue 006) · การ์ดรายละเอียดจากผู้สร้าง · การ์ด "คนเข้าร่วม" (หัวข้อ 12px/900 #7C8699 + เส้นคาด #F2EEE3 + avatar 32 + ชื่อ 14px/700) · การ์ด "ไม่เข้าร่วม" dashed #DAD3C2 กดย่อ/ขยาย (caret ▼/▲) · การ์ดค่าปรับ พื้น #F7ECD8 ขอบ 1.5px #E8C98A: ⏰ + หัวข้อ 14px/900 #B27210, ตัวเลขใหญ่ 34px/900 + หน่วย, 3 ช่องย่อย rgba(255,255,255,.7) (ผ่อนผัน / สูงสุด / เงินเข้าที่)
  • Footer: ยังไม่ตอบ → ปุ่มคู่ "เข้าร่วม (+เข้าร่วมแล้ว n คน)" #2E5FB7 : "ไม่เข้าร่วม" outline · เข้าร่วมแล้ว → "อัปเดตและดูตำแหน่งเพื่อน" + "ยกเลิกเข้าร่วม" (dialog ยืนยัน) · ยังไม่ถึงเวลาแชร์ → ปุ่มถูกล็อก #EFEDE6 #9AA3B2 + 🔒 พร้อม countdown · ไม่เข้าร่วม → แถบเทา 🙏 + ปุ่ม "เปลี่ยนใจ เข้าร่วม" outline
L1-c create — สร้างนัดใหม่ ขั้น 1/2
  • ฟอร์ม: ชื่อนัด · วัน (chip 3 ตัวเลือก) · เวลา (เปิด time picker) · สถานที่ (ไป place) · รายละเอียดเพิ่มเติม (textarea)
  • เริ่มอัปเดตและดูตำแหน่งเพื่อน: 1 ชม. (ฟรี) / 3 / 12 / 48 ชม. + ปุ่ม + กำหนดเอง (Pro, สูงสุด 7 วัน, ปรับทีละ 12 ชม.) · แจ้งเตือนนัดตอนสร้างใช้ default 1 ชม. — ปุ่มปรับอยู่ในจอแก้ไขนัด (L1-r)
  • ตัวเลือกที่ล็อกเป็นสีจาง #B9C2D2 พื้น #FAF6EE กดแล้วไป paywall · ปุ่มเลือก: selected = 2px solid #2E5FB7 + ตัว #2E5FB7, ปกติ = 1px solid #E5E0D2 + #4C5A75
L1-b pay — โหมดค่าปรับ + ช่องทางรับเงิน ขั้น 2/2
  • โทนส้มทั้งหน้า: selected = 2px solid #B27210, ปกติ = 1px solid #E5D6BC · toggle เปิด/ปิดค่าปรับ · tab "รายนาที" / "เหมาจ่าย"
  • อัตรา 3 ปุ่ม + ปุ่ม + กำหนดเอง → stepper − / + (ขอบ 2px solid #B27210) · รายนาที: ทีละ 1฿ (1–99) · เหมา: ทีละ 10฿ (10–999)
  • เพดานสูงสุดต่อคน (รายนาทีเท่านั้น): 50 / 100 / 200 / ไม่จำกัด — ฟรีได้แค่ 50 · สิทธิ์ฟรี: รายนาที = 1฿ เพดาน 50฿ · เหมา = 50฿ เท่านั้น (ค่าเกินถูก clamp ตอน render)
  • ช่องทางรับเงิน (สเปกใหม่ 14 ส.ค. 2026 — issue 011, ยึดเหนือ prototype): ชื่อคนถือกองกลาง + เลือกวิธี 3 แบบ default = PromptPay (กรอกเบอร์ — วิธีเดียวที่มี QR ฝังยอด) / โอนบัญชี (ธนาคาร+เลขบัญชี mask+ชื่อบัญชี) / เงินสด (ไม่กรอก — "จ่ายสดหน้างานกับ...") · ปุ่มเลือกโทนส้มเดียวกับ tab · ไม่มีอัปโหลดรูป QR (ตัดจาก MVP ตามเดิม)
L1-p place — ค้นหา / ปักหมุดสถานที่

ช่องค้นหา + ลิสต์สถานที่ (จาก listPlaces) + แผนที่ Leaflet จริง หมุดลากได้/แตะย้าย (สเปกใหม่ 14 ส.ค. 2026 — issue 007 ยึดเหนือ prototype): เลือกจากลิสต์ = ย้ายหมุด+เติมชื่อ แล้วลากปรับต่อได้ · ยืนยัน = ชื่อ+พิกัดจากหมุดปัจจุบันลง draft → กลับฟอร์ม · ไม่ปักหมุดก็สร้างนัดได้เสมอ · tiles = โหมดน่ารัก (CARTO) เดียวกับ L2

time picker (dialog) — เลือกเวลาแบบเลื่อน

คอลัมน์ชั่วโมง : นาที (นาทีทีละ 5) ค่ากลาง 28px/900, ค่าใกล้ 19px #9AA3B2, ไกล 16px #C6C0B2 — กดเลื่อนขึ้น/ลง

L2 map — แผนที่รวม

แผนที่เต็มจอ (Leaflet) + หมุดเพื่อน · การ์ดล่างลิสต์สถานะรายคน + "เช็กอินล่าสุด" · ปุ่ม "อัปเดตตำแหน่งฉัน" (ครั้งแรกเปิด consent sheet) · สิทธิ์เข้าดู: เฉพาะคน RSVP เข้าร่วม · หัวตี้มีปุ่มจบนัด → sheet เช็คชื่อ

สเปกใหม่ 14 ส.ค. 2026 (ยึดเหนือ prototype): ระยะห่างแทน ETA (005 + มติ 14 ส.ค.): ระยะล้วน "ห่าง X" ทุกช่วง ไม่มีวลี/ไอคอน — <1 กม. เป็นเมตรปัดสิบ ("ห่าง 790 ม.") (LivePosition.distanceKm; etaMin คงไทป์รอ OSRM) · ปุ่ม "ถึงแล้ว" เมื่อแชร์พิกัด = รัศมี ≤150 ม. (ARRIVE_RADIUS_M ใน shared — เผื่อ GPS เมืองคลาด 30–100 ม. · ไม่แชร์ = กดได้เสมอ) · หมุดปลายทาง = pin สีแดง + ป้ายชื่อพร้อมบรรทัดทอง "ถึงแล้ว a/b คน" (แทน countdown) · ปุ่มนำทาง (006) เฉพาะนัดมีพิกัด · ปุ่ม 🎯 กลับมาตำแหน่งฉัน เมื่อกำลังแชร์ · โหมดเดียว: น่ารัก CARTO (ตัดมาตรฐาน/toggle) · ไม่มี pill privacy บนแผนที่ (คำอธิบายอยู่ consent sheet ที่เดียว)

L1-r manage — แก้ไขนัด

ใช้ฟอร์มหน้าเดียวกับ create (สอดคล้องกัน) + แจ้งเตือนนัด ก่อนเริ่ม (30 น. / 1 ชม. / 3 ชม.) + การ์ดลัดไปแก้โหมดค่าปรับ + เลื่อนเวลา / ทำซ้ำนัด / ยกเลิกนัด (dialog ยืนยัน)

L5 history — ประวัตินัดของกลุ่ม

Header + ช่องค้นหา (กรองตามชื่อนัด + สถานที่ แบบ realtime), นับผลลัพธ์ "เจอ n นัด", empty state 🔍 "ไม่เจอนัดที่ค้นหา" · แถวนัดกดไป recap

L6 recap — สรุปนัดที่ผ่านมา 2 แท็บ
  • Header #1D3F7E: eyebrow "นัดที่จบแล้ว" #F5C33B, ชื่อนัด, วันที่ · แท็บ pill "สถิตินัดนี้" / "รายละเอียดนัด" (active = พื้นขาว ตัว #1D3F7E; inactive = โปร่ง ตัว #BDD0EE; container rgba(255,255,255,.12) radius 999)
  • แท็บสถิติ: podium 3 อันดับผู้มาถึงก่อน (แท่งสูง 82 / 96–98 / 108–122px นั่งชิดขอบล่างของ header), ตัวเลขสรุป (เข้าร่วม / มาแล้ว / สาย / ไม่มา), ตารางเวลามาถึงจริง, ค่าปรับรวม
  • แท็บรายละเอียดนัด: สถานที่ + รายละเอียดจากผู้สร้าง + รายชื่อคนตอบรับพร้อมสถานะ (มาแล้ว/มาสาย/ไม่มา) + กติกาค่าปรับ
L3 stats — สถิติรายปี Pro
  • Header #1D3F7E + ตัวเลือกปี เลื่อน/กดเปลี่ยน (2567 / 2568 / 2569) — ข้อมูลทั้งหน้าเปลี่ยนตามปี
  • podium 3 อันดับผู้ตรงเวลา + แถวตัวเลข (นัดทั้งหมด / มากันครบ / ค่าปรับรวม ฿ — กัน overflow ด้วย ellipsis)
  • รางวัลประจำปี 6 ใบ: แชมป์สโลวไลฟ์ 🐢 (พื้น #F7ECD8 ขอบ #E8C98A), จอยจอย 🎉, ตัวละครลับ 🕵️, ตัวเปิด 🎬, สายเปย์ 💸, หายตัว 🌀 — วงกลมไอคอน 34px พื้นขาวยื่นคาบขอบซ้ายการ์ด margin-left:-17px + เงา, ขวาชื่อผู้ชนะ 12px/900 #2E5FB7 + ตัวเลข 10px #7C8699 + avatar 26px
  • กราฟจำนวนนัดรายเดือน 12 แท่ง: สูงสุด #2E5FB7, ปกติ #BDD0EE, เดือนว่าง #F2EEE3 (สูง 3px) · ตารางคนสายทั้งหมด (ชื่อ / ครั้ง / นาที / ค่าปรับ)
L4 paywall — Pro

การ์ดฟีเจอร์เทียบ ฟรี vs Pro (นัดต่อเดือน · แผนที่รวม + ETA · สถิติรายปีเต็ม · จัดอันดับแชมป์แต่ละหมวด · โหมดค่าปรับ unlimited · แชร์พิกัดได้นานสุด 7 วัน) · เลือกแผน radio 2 ใบ: รายเดือน 79฿/ด. · รายปี 790฿/ปี (pill "คุ้มกว่า" #F5C33B, ประหยัด 17%) — selected = พื้น #EEF3FB ขอบ 2px solid #2E5FB7 · บรรทัด "หาร 4 คนในกลุ่ม คนละ …" · ปุ่ม "จ่าย {ราคา} ด้วย PromptPay"

Dialogs

overlay rgba(28,39,64,.5), การ์ดขาว radius 20 padding 24/20, shadow 0 20px 50px -20px rgba(28,39,64,.7)

  • ยืนยันยกเลิกเข้าร่วม (🥺) — บอกว่าบอทจะแจ้งกลุ่มว่าคุณไม่สะดวก
  • ยืนยันยกเลิกนัด — ลบพิกัดทั้งหมด
  • นัดซ้ำวันเดียวกัน (🤔) — "ไปที่นัดเดิม" / "สร้างนัดใหม่ซ้อนไปเลย"
  • consent แชร์ตำแหน่ง ครั้งแรกบนแผนที่
💬 Toast

แถบข้อความจากน้องนัดที — ใช้ทุกครั้งที่ทำ action สำเร็จ (น้ำเสียง: ลงท้าย "ค้าบ/ครับ", เป็นมิตร, ไม่ตำหนิ) · ของจริง: เลื่อนลงจากขอบบนจอ, แตะเพื่อปิดทันที (slide กลับ 180ms), auto-clear 4 วิ

§32Interactions & Behavior + State

  • prototype: go(screen) = push จอใหม่เข้า prev แล้วเคลียร์ toast/dialog · back() = pop — ของจริงใช้ Next router (ดู issue 002 เรื่อง push/replace)
  • RSVP: joined / declined แยกกัน — เข้าร่วมแล้วกด "ยกเลิกเข้าร่วม" ต้องยืนยันใน dialog ก่อน (เพราะบอทจะแจ้งกลุ่ม)
  • จำนวนคนเข้าร่วมแสดงเป็นบรรทัดเล็กใต้ข้อความในปุ่ม (ไม่ใช่ badge แยก)
  • ปุ่มดูตำแหน่งถูกล็อกจนถึง shareLead นาทีก่อนเวลานัด — แสดง countdown
  • Gating ฟรี vs Pro: กดตัวเลือกที่ล็อก → ไป paywall ทันที (ไม่มี alert)
  • ค้นหาในหน้าประวัติ: กรองจาก title + place (lowercase, substring)
  • hover ทุกปุ่ม/การ์ดที่กดได้ต้องมี state (ปุ่ม primary เข้มขึ้น, การ์ดเปลี่ยนพื้นเป็น #FCFAF4, outline เปลี่ยนขอบเป็น #2E5FB7)
  • ไม่มี animation ซับซ้อน — ใช้ transition:background .12s,border-color .12s กับการ์ดที่กดได้

State ของ prototype (อยู่ใน component เดียว) — ของจริงแยกเป็น server state ผ่าน ApiClient + UI state ตาม §5.3:

screen, prev[]                       // navigation
mode: normal|empty|limit             // สถานะโควตาของกลุ่ม
joined, declined                     // RSVP ของผู้ใช้
fine, fineKind: perMin|flat, rate, cap, payee   // กติกาค่าปรับ
remind (นาที), shareLead (นาที)      // แจ้งเตือน / เริ่มแชร์พิกัด
date, time, pendH, pendM             // วัน-เวลา + ค่าใน time picker
place, placeSub
self: idle|moving|arrived, consent, geo: granted|denied
mapLocked                            // ปุ่มตำแหน่งถูกล็อกอยู่ไหม
pro, plan: monthly|yearly            // สถานะสมาชิก
statsYear, histQuery, recap, recapTab
showTime/showDup/showCancel/showLeave/showConsent/showDeclineList
toast

§33Design Tokens

ค่าในโค้ดจริงอยู่ apps/web/src/app/globals.css (@theme) — ต้องตรงกับรายการนี้เสมอ ห้ามใส่ hex ใหม่โดยไม่เพิ่ม token ทั้งสองที่

สี

  • พื้นแอป #FAF6EE · พื้นนอกกรอบ #EFE9DC · การ์ดขาว #FFFFFF · การ์ดครีม #FAF6EE / #FFFDF8 / #FCFAF4 (hover)
  • น้ำเงินหลัก #2E5FB7 · น้ำเงินเข้ม #1D3F7E · ฟ้าอ่อน #EEF3FB · ฟ้ากลาง #BDD0EE / #C9DAF3 / #8FAEDA
  • ตัวอักษรหลัก #1C2740 · รอง #4C5A75 · จาง #7C8699 · จางมาก #B9C2D2 / #C6C0B2
  • ขอบ #E5E0D2 · เส้นคั่น #F2EEE3 · ขอบ dashed #DAD3C2
  • ส้ม/ค่าปรับ #B27210 (ตัว), #F7ECD8 (พื้น), #E8C98A / #E5D6BC / #F0E4CE (ขอบ), #5E4A0C (เข้ม)
  • ทอง Pro #F5C33B (hover #E2AF24) · เขียวสำเร็จ #067A39 / #E2F4E9 · แดงเตือน #B33A3A
  • podium: ทอง #F5C33B · เงิน #D8DCE4 · ทองแดง #E0B98C

Spacing

2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 26px (การ์ด padding 12–16, ช่องไฟระหว่างการ์ด 8–14)

Typography

'Noto Sans Thai','Sukhumvit Set',system-ui,sans-serif (mono: 'IBM Plex Mono' เฉพาะป้ายในแผงเดโม) · ขนาด 9 / 10 / 11 / 12 / 13 / 14 / 15 / 16 / 17 / 19 / 21 / 24 / 25 / 27 / 34px · น้ำหนัก 500 / 700 / 900 (ใช้ 900 กับหัวข้อและตัวเลขเสมอ) · ตัวเลขทุกที่ใช้ font-variant-numeric: tabular-nums (class .num)

Radius

6 / 7 / 9 / 10 / 12 / 14 / 16 / 18 / 20 / 26 / 28 (กรอบเครื่อง) / 999 (pill)

Shadow

  • ปุ่ม primary 0 10px 22px -12px rgba(46,95,183,.9)
  • dialog 0 20px 50px -20px rgba(28,39,64,.7)
  • avatar ลอย 0 2px 6px -2px rgba(28,39,64,.35)

§34Assets

  • demo/NongNutTee.png — โลโก้/มาสคอต (ใช้จริงใน apps/web/public/NongNutTee.png ผ่าน next/image ที่จอ O1 + empty state)
  • ไอคอนอื่นทั้งหมดเป็น emoji (➕ 📍 ⏰ 🗺️ 🔒 🐢 🎉 🕵️ 🎬 💸 🌀 🥇 👑 🥺 🤔 🙏 🔍) — รางวัลตั้งใจให้เป็น emoji ตลอด
  • แผนที่: ของจริงใช้ Leaflet + OSM แล้ว · avatar: ตอนนี้เป็นวงกลมตัวอักษรสี deterministic — Phase B เปลี่ยนเป็นรูปโปรไฟล์ LINE (hotlink CDN)