น้องนัดที (NongNutTee)
เอกสารเทคนิครวมเล่มเดียว: สถาปัตยกรรมจากโค้ดจริง + tech plan + infra/ราคา + design brief + สเปก UX รายจอ — จุดที่เอกสารเดิมล้าสมัยกว่าโค้ดถูกแก้ให้ตรงแล้ว (§12) · อ่านคู่กับ prd.md, pitch.md และ prototype ใน demo/ ซึ่งเป็น source of truth ของพฤติกรรมทุกจอ
สารบัญ
สถาปัตยกรรมจากโค้ดจริง
§1ภาพรวมและขอบเขต
น้องนัดทีคือ LINE Bot + LIFF app สำหรับนัดหมายกลุ่ม: สร้างนัด → เพื่อนกดเข้าร่วมจากการ์ดในแชต → ช่วงใกล้นัดเปิดแผนที่รวมดูว่าใครถึงไหน → บันทึกเวลาถึงจริง คิดค่าปรับคนสาย → สรุปผลต่อนัด/ต่อปี
การพัฒนาแบ่งสองเฟส และสถาปัตยกรรมทั้งหมดถูกออกแบบรอบรอยต่อระหว่างสองเฟสนี้:
| Phase A (ทำเสร็จแล้ว) | Phase B (แผน — ยังไม่เริ่ม) | |
|---|---|---|
| ขอบเขต | จอ LIFF ครบ 13 จอ + business logic + E2E | Backend จริง + LINE webhook + bot + scheduler + Stripe |
| ข้อมูล | MockApiClient in-memory + localStorage | Postgres (Supabase) ผ่าน REST (Next Route Handlers) |
| ตัวตน/พิกัด | mock ทั้งหมด (MockLiffService, MockGeoService) | LIFF SDK จริง + navigator.geolocation |
| เงื่อนไขเหล็ก | — | จอที่ทำใน Phase A ต้องไม่ถูกแก้ — สลับ adapter เท่านั้น |
§2หลักการออกแบบ (ตัวขับการตัดสินใจทั้งระบบ)
- Ports & Adapters — จอห้ามรู้จักแหล่งข้อมูล ทุกจอเข้าถึงข้อมูล/ตัวตน/พิกัด/เวลา ผ่าน interface 4 ตัว (
ApiClient,LiffService,GeoService,Clock) เท่านั้น - Business logic เขียนครั้งเดียวที่
packages/shared— ค่าปรับ/โควตา/กันนัดซ้อน/shareLead ใช้ร่วมกันทั้ง UI, mock และ backend อนาคต จะไม่มีวันเพี้ยนกันเอง และสิทธิ์ฟรีต้อง clamp ที่ shared เสมอ (ไม่เชื่อ client) - Derive, don't store — โควตาเดือน, no-show, สถานะ live, สถิติรายปี ไม่มีตารางเก็บ คำนวณจากข้อมูลดิบเสมอ (กันบั๊ก sync)
- SPA-first — LIFF ไม่ต้องการ SSR/SEO ทุกหน้าเป็น client component (ยกเว้น
/ที่ redirect) เหตุผลที่ยังใช้ Next.js: Phase B ได้ Route Handlers เป็น REST + webhook ในโปรเจกต์เดียว - Privacy โดยโครงสร้าง (PDPA) — พิกัดถูกลบเมื่อนัดจบ เหลือแค่เวลาถึง พฤติกรรมนี้เป็นจริงแม้ใน mock และมี backstop จบนัดอัตโนมัติ T+2 ชม.
- เวลาเก็บ 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 · …/denied | O1–O3 onboarding + ขออนุญาตตำแหน่ง + กรณีไม่อนุญาต |
…/create → …/create/fine | L1-c → L1-b ฟอร์มสร้างนัด 2 ขั้น (ขั้น 2 รวม flow แก้ไข + dup dialog) |
…/place | L1-p เลือก/ปักหมุดสถานที่ (เขียนลง draft store แล้ว router.back()) |
…/events/[eventId] | L1-i รายละเอียดนัด + RSVP 3 สถานะ + ล็อกปุ่มแผนที่จนถึง shareLead |
…/events/[eventId]/map | L2 แผนที่ Leaflet + consent sheet + สถานะ idle/moving/arrived + เช็คชื่อจบนัด (ไฟล์ใหญ่สุด ~670 บรรทัด) |
…/events/[eventId]/manage | L1-r แก้ไขนัด + ยกเลิกนัด |
…/events/[eventId]/recap | L6 สรุปนัด 2 แท็บ + podium |
…/history | L5 ประวัติ + ค้นหา (debounce 300ms) |
…/stats | L3 สถิติรายปี + รางวัล 6 หมวด |
…/pro | L4 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-frame480px — 375 คือฐานออกแบบ ไม่ใช่ขนาดล็อก (issue 009), header/footer ตรึง เนื้อหา scroll, footer หลบ safe area iPhone),TopBar,FooterBar,PrimaryButton/OutlineButton,Avatar(สี deterministic จาก userId),EventPhasePill,ToastBar,Dialogsrc/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 ปิดแล้ว) |
| Toast | zustand useToast | auto-clear 4 วิ แสดงผ่าน ToastBar |
| Navigation | Next 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.ts | interface 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 / ended | derive จาก startsAt + status + Clock |
payee.ts | sanitizePayee — ช่องทางรับเงิน 3 แบบ (issue 011) | ตัด field ที่ไม่ตรง method · default ฝั่งจอ = promptpay |
distance.ts | distanceKm (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[])(เช็คชื่อ) ·rsvp→EventDetail·checkin(eventId, LatLng|null)·markArrived·getLive·getRecap·listStatYears·getYearStats·getSubscription·startCheckoutLiffService(โปรไฟล์/in-client/close),GeoService(getPosition/watch/permissionState — mock คือจุดวิ่งตามสคริปต์ทุก 3 วิ),Clock(ทำให้เทสต์ countdown/ค่าปรับด้วยเวลาปลอมได้)- จุดสลับ Phase B มีจุดเดียว:
apps/web/src/lib/adapters/index.ts— ไม่มีจอไหน import adapter ตัวจริงตรงๆ · สวิตช์ผ่าน envNEXT_PUBLIC_ADAPTER=mock|http(defaultmock) 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 keynnt-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 tokens | Tailwind 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 จุดเดียว |
| Feedback | toast กลางระบบ (โทน "ค้าบ/ครับ") + dialog ยืนยันทุก action ทำลายล้าง (ยกเลิกเข้าร่วม/ยกเลิกนัด/นัดซ้อน) |
| Error handling | mock แทบไม่ล้มเหลว จอจึงยังไม่มีชั้น error/retry จริงจัง — ต้องเติมตอน HttpApiClient (ดู §13) |
§10Testing
| ชั้น | เครื่องมือ | สถานะจริง |
|---|---|---|
| Unit — business logic | vitest ใน packages/shared | ทำแล้ว ครอบ fine/quota/dup/shareLead/eventPhase รวม edge timezone |
| Component | — | ยังไม่มี (§17 วางไว้ ยังไม่ถูกทำ) |
| E2E | Playwright, iPhone 13, port 3100 | 15 เคส journeys J1–J6 · draft persist ×2 (001) · responsive 360/430 (009) · วิธีรับเงิน J7–J8 (011) · place/นำทาง/สลับโหมดแผนที่ ×3 (005–008) |
| Visual snapshot | Playwright toHaveScreenshot | 4 จอ 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 |
| 5 | Deploy (A8 ข้อสุดท้าย) | ค้าง — รอบัญชี Vercel/Cloudflare ของเจ้าของ |
| 6 | apps/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):replace5 จุด - 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 |
แผนงาน + สัญญา API + Schema
§15Stack
15.1 Phase A — LIFF App (ใช้จริงแล้ว)
| ส่วน | เลือกใช้ | เหตุผล |
|---|---|---|
| Framework | Next.js 15 (App Router) + TypeScript — SPA-first | แอป LIFF ไม่ต้องการ SSR/SEO จึงเขียนเป็น client components เกือบทั้งหมด — เหตุผลที่เลือก Next: Phase B ใช้ Route Handlers เป็น REST API + LINE webhook ในโปรเจกต์เดียว ลดของที่ต้อง deploy |
| Routing | App Router — จอ 13 จอ ใช้ route กลุ่มเดียว /g/[groupId]/... | ตรงกับ prev[] ของ prototype |
| State | Zustand (เฉพาะ draft + toast) | เบา, โครงตรงกับ state เดียวของ prototype, ไม่ over-engineer |
| Styling | Tailwind 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 เสมอ |
| QR | promptpay-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 จริง |
| Deploy | Vercel free tier หรือ Cloudflare Pages | ได้ลิงก์จริงให้คนกดเล่นบนมือถือทันที |
15.2 Phase B — Backend + Bot (วางไว้ก่อน ยังไม่ทำ)
| ส่วน | เลือกใช้ |
|---|---|
| REST API + LINE webhook | Next.js Route Handlers ในแอปเดียวกัน (app/api/...) — ไม่ต้องมี server แยก |
| LINE | @line/bot-sdk (push/reply + Flex) · @line/liff (ฝั่ง front) |
| DB | Postgres (Supabase free tier) + Drizzle ORM |
| Scheduler | ต้องอยู่นอก serverless: worker เล็กบน Railway/Fly อ่านตาราง scheduled_jobs ทุกนาที (หรือ Upstash QStash) — Vercel Cron ฟรีทำได้แค่รายวัน ไม่พอ |
| Payment | Stripe (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 |
| Hosting | Vercel (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 ปลอม) | ทำแล้ว |
| Component | Vitest + Testing Library | RSVP 3 สถานะ + dialog ยืนยัน · gating ฟรี→paywall · ฟอร์ม 2 ขั้น validation | ยังไม่ทำ |
| E2E | Playwright (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 + supertest | webhook: 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 + deploy | E2E 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 บังคับให้เป็นจริง
- Privacy โดยโครงสร้าง —
location_pingsผูก TTL กับสถานะนัด (ลบเมื่อ ended/cancelled) ส่วนarrivalsคือสิ่งเดียวที่เหลือ ตรงกับ copy ในจอ O2: "ลบพิกัดหลังนัดจบ เก็บไว้แค่เวลาที่มาถึง" - โควตา/no-show ไม่มีวัน desync — เพราะไม่ได้เก็บ แต่คำนวณจากข้อมูลจริงเสมอ
- Idempotent ตั้งแต่ DB — PK ซ้อน (event_id, user_id) ทำให้กด RSVP/เช็กอินรัวๆ ได้ผลเดิม,
UNIQUE(event_id, kind)ทำให้เลื่อนนัดไม่ทำให้เตือนซ้ำสองรอบ - สิทธิ์ฟรี 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 พัฒนา |
Infra & ราคา
§22ระดับผู้ใช้ที่ใช้ทั้งส่วนนี้ (อิง Base Case ใน prd.md §11)
| Tier | ช่วงเวลา | กลุ่ม active | ผู้ใช้ (~6 คน/กลุ่ม) | อีเวนต์/เดือน | โหลดสำคัญ/เดือน |
|---|---|---|---|---|---|
| T0 Seed | เดือน 1–2 | 50 | ~300 | ~150 | push 600 · แผนที่ ~1.5k ครั้ง · poll ~45k req |
| T1 Launch | เดือน 3–6 | 1,200 | ~7,000 | ~3,600 | push 14k · แผนที่ ~36k · poll ~1.1M req |
| T2 Growth | เดือน 7–12 | 5,600 | ~34,000 | ~16,000 | push 64k · แผนที่ ~160k · poll ~4.8M req |
| T3 Year-2 | ปีที่ 2 (optimistic) | 20,000 | ~120,000 | ~60,000 | push 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 / T1 | 0฿ (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 / T1 | 0฿ (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 (ตัวเลขจริงที่ตรวจแล้ว — ต้นทุนก้อนใหญ่สุดของระบบ)
| Tier | push/เดือน | แพ็กเกจที่ต้องใช้ | ราคา (รวม VAT) |
|---|---|---|---|
| T0 | 600 | Basic 15,000 msg | ~1,370฿ |
| T1 | 14,400 | Basic 15,000 msg (เฉียดเพดาน — เฝ้าระวัง) | ~1,370฿ |
| T2 | 64,000 | Pro 35,000 + ส่วนเกิน 29,000×0.06฿ | ~3,645฿ |
| T3 | 240,000 | Pro + ส่วนเกิน 205,000×0.06฿ | ~14,200฿ |
เหตุผลไม่มีทางเลือก: ผูกกับ platform โดยธรรมชาติ — คันโยกลดต้นทุนคือสถาปัตยกรรม reply-first/piggyback (prd §7) ไม่ใช่การเปลี่ยนผู้ให้บริการ · LIFF/webhook/Group API ฟรีหมด
23.5 แผนที่ (tiles) — แผนย้ายตามสเกล
| Tier | tiles/เดือน (~800/อีเวนต์) | เลือกใช้ | ราคา | ทางเลือกที่แพ้ |
|---|---|---|---|---|
| T0 | ~120k | OSM สาธารณะ (fair-use) | 0฿ | — |
| T1 | ~2.9M | MapTiler/Stadia free tier หรือ Longdo (POI ไทยดี) | 0–หลักร้อย฿ | Google Maps: เกิน free tier แล้วกระโดดหลักหมื่น — ตัดตั้งแต่แรก |
| T2–T3 | 13M–48M | PMTiles self-host บน Cloudflare R2 (ไฟล์ไทย ~2GB, egress ฟรี) | ~30–70฿ | Mapbox: ~$5/1k loads → หลักหมื่น/เดือนที่สเกลนี้ |
จุดชนะเชิงโครงสร้าง: Leaflet ทำให้ย้ายแหล่ง tiles = แก้ URL บรรทัดเดียว → ต้นทุนแผนที่แทบไม่โตตามผู้ใช้ (สอดคล้อง gross margin 97% ที่ขายไว้)
23.6 อื่นๆ ที่ต้นทุนคงศูนย์ทุก Tier
| ของ | เลือก | ราคา | หมายเหตุ |
|---|---|---|---|
| QR PromptPay | promptpay-qr เจนในเครื่อง | 0฿ ตลอด | มาตรฐานเปิด ไม่มี API/ค่าธรรมเนียม |
| ฟอนต์ | Noto Sans Thai self-host (next/font) | 0฿ | bundle มากับแอป |
| รูป/ไฟล์ | ไม่มี user upload ใน MVP · avatar hotlink LINE CDN | 0฿ | ตัดอัปโหลด QR แล้ว (มติ ส.ค. 2026) |
| Payment รับเงิน Pro | Stripe PromptPay | 1.65% ของรายได้ | แปรผัน ไม่ใช่ fixed — ตามนโยบาย prd §8.3 |
§24Monitoring & Observability
หลักการ: solo dev ต้องรู้ว่า "พังแล้ว" ก่อนผู้ใช้ทัก LINE มาบอก — เลือกของ free tier ที่แจ้งเตือนถึงมือถือได้จริง
| ชั้น | เลือก | ทำอะไร | ทางเลือกที่แพ้ | T0–T1 | T2 | T3 |
|---|---|---|---|---|---|---|
| Error tracking | Sentry (Next.js SDK + worker) | จับ exception ทั้ง LIFF/API/worker พร้อม stack trace + แจ้งเตือน | GlitchTip (เวลา ops), Axiom | 0฿ (5k errors/ด.) | 0–950฿ | ~950฿ |
| Uptime + cron sanity | Better Stack หรือ UptimeRobot | ping /api/health ทุกนาที + heartbeat monitor ของ worker (เงียบเกิน 5 นาที = ตาย → แจ้งทันที — สำคัญสุดเพราะ push เตือนนัดคือหัวใจ) | Pingdom (แพง) | 0฿ | 0–360฿ | ~360฿ |
| Logs | Vercel logs + Railway logs | ไล่เหตุการณ์ webhook/API | Datadog (overkill+แพงมาก) | 0฿ | 0฿ | 0–700฿ |
| DB health | Supabase dashboard + alert email ในตัว | connection/disk/slow query | pganalyze (แพง) | 0฿ | 0฿ | 0฿ |
| Product analytics | PostHog Cloud | event: สร้างนัด, 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 Seed | T1 Launch | T2 Growth | T3 Year-2 |
|---|---|---|---|---|
| Vercel (front+API) | 0 | 0 | 720 | ~1,100 |
| Supabase (DB) | 0 | 0 | 900 | ~1,350 |
| Railway (worker) | 180 | 180 | 270 | 360 |
| LINE OA | 1,370 | 1,370 | 3,645 | 14,200 |
| Tiles แผนที่ | 0 | 0–200 | ~50 | ~70 |
| Monitoring ทั้งชุด | 0 | 0 | ~950 | ~2,100 |
| Domain เฉลี่ย | 50 | 50 | 50 | 50 |
| รวม | ~1,600 | ~1,800 | ~6,600 | ~19,200 |
| เทียบรายได้ Base Case (PRD) | 0 | ~4,400–6,600 | ~11,000–21,000 | ~100,000+ |
- LINE คือ 60–75% ของบิลทุก Tier — ทุกการ optimize ควรทุ่มที่ reply-first/piggyback ก่อนสิ่งอื่นเสมอ
- ตัวเลข T2 (~6,600฿) สูงกว่าที่ตาราง
prd.md§10 เคยประมาณ (~4,300฿) เพราะรอบนี้รวม monitoring + Vercel/Supabase paid ที่ต้องจ่ายจริงเมื่อโต — ควร sync ตัวเลขนี้กลับเข้า PRD ในรอบถัดไป - ทุก 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 — แก้บั๊กถูกกว่าอัปแพลน |
Design Brief
§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 Reply | F1 |
| wake word "น้องนัดที" → ตอบพร้อมปุ่มเข้า LIFF | Flex/text | F0 |
| เปิด LIFF ครั้งแรก: welcome → อธิบายตำแหน่ง → เคสไม่อนุญาต | LIFF | O1 → O2 → O3 |
| หน้าแรก: นัดกำลังจะถึง + นัดผ่านมา + โควตา (3 โหมด) | LIFF (Full) | L1 |
| รายละเอียดนัด + RSVP | LIFF | L1-i |
| ฟอร์มสร้างนัด ขั้น 1/2 (+time picker, +ปักหมุด) | LIFF | L1-c (+dialog, L1-p) |
| โหมดค่าปรับ + ช่องรับเงิน ขั้น 2/2 | LIFF | L1-b |
| การ์ดนัดลงกลุ่ม / หลัง RSVP | Flex | F2 / F2-b |
| เตือน + เช็กอิน (Push #1–2) | Flex | F3 |
| คำสั่ง "มายัง" → สถานะสดในแชต | Flex | F4 |
| ถึงเวลานัด + แท็กคนยังไม่ถึง (Push #3) | Flex | F5 |
| แผนที่รวม + consent + สถานะตัวเอง 3 แบบ | LIFF (Full) | L2 |
| แก้ไขนัด / เลื่อน / ยกเลิก | LIFF + Flex แจ้งกลุ่ม | L1-r + F8 |
| สรุปจบนัด "หอเกียรติยศ" (Push #4) | Flex → ดูเต็มใน LIFF | F6 / L6 |
| teaser สถิติ → สถิติรายปีเต็ม (Pro) | Flex → LIFF | F7 / L3 |
| ประวัตินัดกลุ่ม + ค้นหา | LIFF | L5 |
| Paywall Pro | LIFF | L4 |
หลักการแบ่ง (ยังใช้ตัดสินฟีเจอร์ใหม่): ทั้งกลุ่มเห็นร่วมกันในแชต = Flex · โต้ตอบซับซ้อน/ข้อมูลรายบุคคล/ฟอร์ม/แผนที่ = LIFF · ชั่วคราวเร่งด่วน = Quick Reply
§29Design decisions ที่ dev ต้องรักษา + เกณฑ์ผ่าน
สิ่งที่ prototype ตัดสินใจแล้ว
- Onboarding แบบไม่บังคับ — O3 ทำให้คนไม่ให้ GPS ยังใช้งานได้ (กด "ถึงแล้ว" เอง) ไม่ใช่ dead end
- ค่าปรับมี "ผ่อนผัน 5 นาทีแรก" เสมอ + ฟรี clamp ที่ 1฿/นาที เพดาน 50฿ หรือเหมา 50฿
- ปุ่มแผนที่ล็อกจนถึง shareLead (ฟรี 1 ชม. / Pro ถึง 7 วัน) — มี countdown บอกชัด
- กด "ถึงแล้ว" = หยุดแชร์ทันที — privacy line บนแผนที่เปลี่ยนตามสถานะ
- no-show แยกจากคนสาย — ไม่คิดเงิน แต่จดสถิติ (โผล่เป็นรางวัล "แชมป์หายตัว")
- ยกเลิกนัด = คืนโควตา + ลบพิกัด + แจ้งกลุ่ม — dialog อธิบายผลทั้ง 3 ข้อก่อนยืนยัน
- ทุก action สำเร็จมี toast จากน้องนัดที — คือช่องทางหลักที่คาแรกเตอร์ปรากฏ
- 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 กดถึงแล้วเอง)
สเปก UX รายจอ
§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สเปกรายจอ
- พื้น
#1D3F7Eเต็มจอ, จัดกลางแนวตั้ง · โลโก้ 104×104 radius 26 บนพื้น#FFFDF8 - eyebrow 11px/900
#F5C33Bletter-spacing .14em → หัวเรื่อง 25px/900#fffline-height 1.35 → คำอธิบาย 13px#BDD0EEline-height 1.7 - 3 การ์ดบอกความสามารถ: พื้น
rgba(255,255,255,.12)radius 14 padding 12/14, emoji 19px + ข้อความ 13px#fff - ปุ่มหลัก "เริ่มใช้งาน": พื้น
#F5C33Bตัว#1C274017px/900 padding 16 radius 16 (hover#E2AF24) → ไป O2 - บรรทัดท้าย 11px
#8FAEDA— ข้อความความเป็นส่วนตัว
- Header ขาว 12/16 + เส้นล่าง
#E5E0D2: ← / "อนุญาตตำแหน่ง" 15px/900 / "2 จาก 2" 11px#7C8699 - การ์ดฟ้า
#EEF3FBradius 18: ภาพแผนที่จำลอง 96×78 + หัวเรื่อง 18px/900 + คำอธิบาย 13px#4C5A75 - 3 การ์ดขาวขอบ
#E5E0D2: ⏱️ แชร์เป็นช่วงเวลา / 👥 เห็นแค่คนในนัด / 🗑️ ลบพิกัดหลังนัดจบ (12px line-height 1.6) - Footer: "อนุญาตตำแหน่ง" (
#2E5FB7, shadow0 10px 22px -12px rgba(46,95,183,.9)) → list + toast / "ยังไม่อนุญาตตอนนี้" (text 13px#7C8699) → O3
- การ์ดส้ม
#F7ECD8ตัวอักษร#B27210: บอกผลกระทบ (เพื่อนไม่เห็นตำแหน่ง ต้องกด "ถึงแล้ว" เอง) - การ์ดขาว: ขั้นตอนเปิดทีหลัง 3 ขั้น (badge เลข 22×22 radius 7
#EEF3FB/#2E5FB7) - Footer: "ลองอนุญาตอีกครั้ง" (primary) / "ใช้งานต่อโดยไม่แชร์พิกัด" (outline)
- Header ขาว padding 10/14: avatar 36 วงกลม
#2E5FB7+ ชื่อกลุ่ม 15px/900 + "พี่มายด์ · 4 คนในกลุ่ม" 11px#7C8699· โหมด limit เพิ่ม pill "3/3 เดือนนี้" 11px/900#96700Eบน#F7ECD8 - การ์ดนัดที่กำลังจะถึง (กดทั้งการ์ดไป detail, hover
#FCFAF4): พื้นขาว ขอบ#E5E0D2radius 16 padding 12 — ซ้ายกล่องวันที่ 62×62 radius 14#EEF3FB(เลขวัน 27px/900#2E5FB7+ เดือนย่อ 12px/700), กลาง 3 บรรทัด: ชื่อนัด 16px/900 · "ศ. 19:00 · หมูกระทะแสนสิริ" 12px#4C5A75tabular-nums · บรรทัดสถานะ 11px ("ยังไม่ได้ตอบ"#B27210/ "คุณเข้าร่วมแล้ว"#067A39/ "ไม่เข้าร่วม"#7C8699), ขวา›17px#B9C2D2 - นัดที่ผ่านมา: แถวรูปแบบเดียวกัน (กล่องวันที่ 52–56px) + บรรทัด "คุณเข้าร่วม"
#067A39→ กดไป recap - empty: การ์ด dashed ชวนสร้างนัดแรก · limit: การ์ดชนลิมิต + ปุ่มอัป Pro (ปุ่มสร้างนัดถูกซ่อน)
- แถบโควตา (กดได้ → paywall): พื้น
#FDF6E7ขอบ#F0E4CEradius 12 — "ใช้ไป 2/3 นัดของเดือนนี้" + progress 3 ขีด (สูง 4px, ใช้แล้ว#B27210, ว่าง#EFE3CC) + pill "Pro 👑"#F5C33B - Footer: "➕ สร้างนัดใหม่" (primary 17px/900 padding 16 radius 16) + ปุ่มเล็กคู่ "ประวัตินัดกลุ่ม" / "สถิติรายปี" (+pill PRO)
- Header น้ำเงินเข้ม
#1D3F7Epadding 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
- ฟอร์ม: ชื่อนัด · วัน (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
- โทนส้มทั้งหน้า: 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 ตามเดิม)
ช่องค้นหา + ลิสต์สถานที่ (จาก listPlaces) + แผนที่ Leaflet จริง หมุดลากได้/แตะย้าย (สเปกใหม่ 14 ส.ค. 2026 — issue 007 ยึดเหนือ prototype): เลือกจากลิสต์ = ย้ายหมุด+เติมชื่อ แล้วลากปรับต่อได้ · ยืนยัน = ชื่อ+พิกัดจากหมุดปัจจุบันลง draft → กลับฟอร์ม · ไม่ปักหมุดก็สร้างนัดได้เสมอ · tiles = โหมดน่ารัก (CARTO) เดียวกับ L2
คอลัมน์ชั่วโมง : นาที (นาทีทีละ 5) ค่ากลาง 28px/900, ค่าใกล้ 19px #9AA3B2, ไกล 16px #C6C0B2 — กดเลื่อนขึ้น/ลง
แผนที่เต็มจอ (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 ที่เดียว)
ใช้ฟอร์มหน้าเดียวกับ create (สอดคล้องกัน) + แจ้งเตือนนัด ก่อนเริ่ม (30 น. / 1 ชม. / 3 ชม.) + การ์ดลัดไปแก้โหมดค่าปรับ + เลื่อนเวลา / ทำซ้ำนัด / ยกเลิกนัด (dialog ยืนยัน)
Header + ช่องค้นหา (กรองตามชื่อนัด + สถานที่ แบบ realtime), นับผลลัพธ์ "เจอ n นัด", empty state 🔍 "ไม่เจอนัดที่ค้นหา" · แถวนัดกดไป recap
- Header
#1D3F7E: eyebrow "นัดที่จบแล้ว"#F5C33B, ชื่อนัด, วันที่ · แท็บ pill "สถิตินัดนี้" / "รายละเอียดนัด" (active = พื้นขาว ตัว#1D3F7E; inactive = โปร่ง ตัว#BDD0EE; containerrgba(255,255,255,.12)radius 999) - แท็บสถิติ: podium 3 อันดับผู้มาถึงก่อน (แท่งสูง 82 / 96–98 / 108–122px นั่งชิดขอบล่างของ header), ตัวเลขสรุป (เข้าร่วม / มาแล้ว / สาย / ไม่มา), ตารางเวลามาถึงจริง, ค่าปรับรวม
- แท็บรายละเอียดนัด: สถานที่ + รายละเอียดจากผู้สร้าง + รายชื่อคนตอบรับพร้อมสถานะ (มาแล้ว/มาสาย/ไม่มา) + กติกาค่าปรับ
- 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) · ตารางคนสายทั้งหมด (ชื่อ / ครั้ง / นาที / ค่าปรับ)
การ์ดฟีเจอร์เทียบ ฟรี vs Pro (นัดต่อเดือน · แผนที่รวม + ETA · สถิติรายปีเต็ม · จัดอันดับแชมป์แต่ละหมวด · โหมดค่าปรับ unlimited · แชร์พิกัดได้นานสุด 7 วัน) · เลือกแผน radio 2 ใบ: รายเดือน 79฿/ด. · รายปี 790฿/ปี (pill "คุ้มกว่า" #F5C33B, ประหยัด 17%) — selected = พื้น #EEF3FB ขอบ 2px solid #2E5FB7 · บรรทัด "หาร 4 คนในกลุ่ม คนละ …" · ปุ่ม "จ่าย {ราคา} ด้วย PromptPay"
overlay rgba(28,39,64,.5), การ์ดขาว radius 20 padding 24/20, shadow 0 20px 50px -20px rgba(28,39,64,.7)
- ยืนยันยกเลิกเข้าร่วม (🥺) — บอกว่าบอทจะแจ้งกลุ่มว่าคุณไม่สะดวก
- ยืนยันยกเลิกนัด — ลบพิกัดทั้งหมด
- นัดซ้ำวันเดียวกัน (🤔) — "ไปที่นัดเดิม" / "สร้างนัดใหม่ซ้อนไปเลย"
- consent แชร์ตำแหน่ง ครั้งแรกบนแผนที่
แถบข้อความจากน้องนัดที — ใช้ทุกครั้งที่ทำ 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)