สร้าง LINE Flex Message จากคำสั่งภาษาไทย 6 แบบ ดูตัวอย่างในแผง และได้ JSON พร้อมส่ง LINE Messaging API

สร้าง LINE Flex Message จากคำสั่งภาษาไทยสั้นๆ แทนการเขียน JSON เอง มี template 6 แบบ (สินค้า, ใบเสร็จ, โปรไฟล์, เมนูอาหาร, บัตรสมาชิก, carousel) ดูตัวอย่างได้ในแผงด้านข้างซึ่งวาดจาก JSON จริง กดปุ่มในตัวอย่างเพื่อดูว่า LINE จะส่งข้อความอะไร แล้วเอา JSON ไปส่งผ่าน LINE Messaging API ได้ทันที Flex ที่สร้างไว้จะจำไว้ข้าม session
| คำสั่ง | ทำอะไร | ||
|---|---|---|---|
/flex product เสื้อยืดลายไทย 299 399 | สินค้า: ชื่อ ราคา [ราคาเดิม] [https://รูป] | ||
/flex receipt ร้านส้มตำ ส้มตำ:60,ลาบ:70 | ใบเสร็จ รวมยอดให้เอง (หรือ /flex receipt ร้าน 350) | ||
| `/flex profile สมชาย ใจดี \ | นักพัฒนา` | โปรไฟล์: ชื่อ \ | ตำแหน่ง |
/flex menu ร้านป้าแดง ส้มตำ:60,ลาบ:70 | เมนูอาหาร กดแต่ละแถวเพื่อสั่ง | ||
/flex member สมชาย 1250 [Gold] | บัตรสมาชิก มีแถบความคืบหน้าไประดับถัดไป | ||
/flex carousel เสื้อ:299,หมวก:199 | หลายสินค้าเลื่อนดูได้ (2-12 ชิ้น) | ||
/flex demo | สร้างตัวอย่างครบ 6 แบบ | ||
/flex list | ดูรายการที่สร้างไว้ | ||
/flex show <id> | ดูตัวอย่างในแผง | ||
/flex json <id> | JSON พร้อมส่ง LINE Messaging API | ||
/flex export <id> | บันทึกเป็น flex-<id>.json | ||
/flex delete <id> / /flex clear | ลบ / ลบทั้งหมด |
/plugin install flex-message-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
ส้มตำ:60,ลาบ:70 เป็นข้อมูล$.storehttps:// (LINE บังคับ) ถ้าไม่ใส่ การ์ดจะไม่มีรูปregister.tsxhooks/register.tsx 509 lines1/**
2 * ============================================================================
3 * MOD 12: Flex Message TH - สร้าง LINE Flex Message จากคำสั่งภาษาไทย (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: LINE Flex Message (https://developers.line.biz/en/docs/messaging-api/using-flex-messages/)
6 *
7 * ปกติ Flex Message ต้องเขียน JSON ยาวๆ เอง Mod นี้สร้างให้จากคำสั่งสั้นๆ
8 *
9 * หน้าที่ของ Mod นี้:
10 * 1. Template ไทย 6 แบบ: สินค้า, ใบเสร็จ, โปรไฟล์, เมนูอาหาร, บัตรสมาชิก, carousel
11 * 2. ดูตัวอย่างในแผงด้านข้าง (วาดจาก JSON จริง กดปุ่มดูได้ว่า LINE จะส่งข้อความอะไร)
12 * 3. /flex json ได้ JSON พร้อมส่ง LINE Messaging API, /flex export บันทึกเป็นไฟล์
13 * 4. จำ Flex ที่สร้างไว้ข้าม session ด้วย $.store
14 */
15
16import type { EngineInterface, Register } from 'claude-code'
17
18// ==========================================================================
19// บทที่ 1: โครงสร้างของ Flex Message (ตามสเปคของ LINE)
20// ==========================================================================
21// bubble = การ์ด 1 ใบ มีส่วน header / hero (รูปใหญ่) / body / footer
22// carousel = bubble หลายใบเลื่อนดูได้
23// ข้างในเป็น component: box (กล่อง), text, button, image, separator
24type FlexAction = { type: 'message' | 'uri'; label: string; text?: string; uri?: string }
25
26interface FlexNode {
27 type: string
28 layout?: 'vertical' | 'horizontal' | 'baseline'
29 contents?: FlexNode[]
30 text?: string
31 color?: string
32 size?: string
33 weight?: 'regular' | 'bold'
34 decoration?: 'line-through'
35 align?: 'start' | 'center' | 'end'
36 wrap?: boolean
37 flex?: number
38 margin?: string
39 spacing?: string
40 backgroundColor?: string
41 paddingAll?: string
42 cornerRadius?: string
43 width?: string
44 height?: string
45 url?: string
46 aspectRatio?: string
47 aspectMode?: 'cover' | 'fit'
48 style?: 'primary' | 'secondary' | 'link'
49 action?: FlexAction
50}
51
52interface FlexBubble {
53 type: 'bubble'
54 header?: FlexNode
55 hero?: FlexNode
56 body?: FlexNode
57 footer?: FlexNode
58}
59
60interface FlexCarousel {
61 type: 'carousel'
62 contents: FlexBubble[]
63}
64
65interface SavedFlex {
66 id: number
67 name: string
68 altText: string // ข้อความที่แสดงในแจ้งเตือนของ LINE (บังคับต้องมี)
69 flex: FlexBubble | FlexCarousel
70 createdAt: number
71}
72
73const LINE_GREEN = '#06c755'
74
75// ฟังก์ชันช่วยสร้าง component สั้นๆ
76const text = (value: string, extra: Partial<FlexNode> = {}): FlexNode => ({ type: 'text', text: value, ...extra })
77const box = (layout: FlexNode['layout'], contents: FlexNode[], extra: Partial<FlexNode> = {}): FlexNode => ({ type: 'box', layout, contents, ...extra })
78const button = (label: string, message: string, extra: Partial<FlexNode> = {}): FlexNode => ({
79 type: 'button',
80 action: { type: 'message', label, text: message },
81 ...extra,
82})
83const baht = (n: number) => `฿${n.toLocaleString('en-US')}`
84
85// ==========================================================================
86// บทที่ 2: Template ไทย - แต่ละอันคือฟังก์ชันที่คืน bubble
87// ==========================================================================
88type Item = { name: string; price: number }
89
90function productBubble(name: string, price: number, fullPrice?: number, imageUrl?: string): FlexBubble {
91 const priceRow = [text(baht(price), { weight: 'bold', size: 'xl', color: LINE_GREEN, flex: 0 })]
92 if (fullPrice && fullPrice > price) {
93 priceRow.push(text(baht(fullPrice), { size: 'sm', color: '#aaaaaa', decoration: 'line-through', margin: 'md', flex: 0 }))
94 }
95 return {
96 type: 'bubble',
97 // hero (รูป) ใส่เฉพาะเมื่อมี URL รูปจริง เพราะ LINE บังคับต้องเป็น https
98 ...(imageUrl ? { hero: { type: 'image', url: imageUrl, size: 'full', aspectRatio: '20:13', aspectMode: 'cover' as const } } : {}),
99 body: box('vertical', [text(name, { weight: 'bold', size: 'xl', wrap: true }), box('baseline', priceRow, { margin: 'md' })]),
100 footer: box('horizontal', [button('ใส่ตะกร้า', `ใส่ ${name} ลงตะกร้า`, { style: 'secondary' }), button('ซื้อเลย', `ซื้อ ${name}`, { style: 'primary', color: LINE_GREEN })], { spacing: 'sm' }),
101 }
102}
103
104function receiptBubble(shop: string, items: Item[]): FlexBubble {
105 const total = items.reduce((sum, item) => sum + item.price, 0)
106 return {
107 type: 'bubble',
108 header: box('vertical', [text(shop, { weight: 'bold', size: 'xl', color: '#ffffff' }), text('ใบเสร็จรับเงิน', { size: 'sm', color: '#ffffff' })], {
109 backgroundColor: LINE_GREEN,
110 paddingAll: '20px',
111 }),
112 body: box('vertical', [
113 ...items.map(item => box('horizontal', [text(item.name, { size: 'sm', color: '#555555', flex: 4 }), text(baht(item.price), { size: 'sm', align: 'end', flex: 2 })], { margin: 'md' })),
114 { type: 'separator', margin: 'xl' },
115 box('horizontal', [text('รวมทั้งหมด', { size: 'sm', weight: 'bold' }), text(baht(total), { size: 'sm', weight: 'bold', align: 'end' })], { margin: 'xl' }),
116 ]),
117 footer: box('vertical', [button('ดูรายละเอียด', `ดูรายละเอียดใบเสร็จ ${shop}`, { style: 'primary', color: LINE_GREEN })]),
118 }
119}
120
121function profileBubble(name: string, role: string): FlexBubble {
122 return {
123 type: 'bubble',
124 body: box('vertical', [text(`👤 ${name}`, { weight: 'bold', size: 'lg' }), text(role, { size: 'sm', color: '#888888', wrap: true })]),
125 footer: box('horizontal', [button('ติดต่อ', `ขอติดต่อ ${name}`, { style: 'primary', color: LINE_GREEN })]),
126 }
127}
128
129function menuBubble(restaurant: string, dishes: Item[]): FlexBubble {
130 return {
131 type: 'bubble',
132 header: box('vertical', [text(`🍜 ${restaurant}`, { weight: 'bold', size: 'lg', color: '#ffffff' })], { backgroundColor: '#ff6b35', paddingAll: '15px' }),
133 // กดแต่ละแถวเพื่อสั่งได้: ใส่ action ที่ box ของแถวนั้น
134 body: box(
135 'vertical',
136 dishes.map(dish =>
137 box('horizontal', [text(dish.name, { size: 'sm', flex: 3 }), text(baht(dish.price), { size: 'sm', align: 'end', flex: 1, weight: 'bold', color: '#ff6b35' })], {
138 margin: 'md',
139 action: { type: 'message', label: dish.name.slice(0, 20), text: `สั่ง ${dish.name}` },
140 }),
141 ),
142 ),
143 footer: box('vertical', [button('ดูเมนูทั้งหมด', `ดูเมนูทั้งหมดของ ${restaurant}`, { style: 'primary', color: '#ff6b35' })]),
144 }
145}
146
147// ระดับสมาชิกตามแต้ม -> คำนวณ progress ไประดับถัดไป
148const TIERS = [
149 { name: 'Silver', min: 0 },
150 { name: 'Gold', min: 1000 },
151 { name: 'Platinum', min: 5000 },
152]
153
154function memberBubble(name: string, points: number, level?: string): FlexBubble {
155 const index = TIERS.findLastIndex(t => points >= t.min)
156 const tier = TIERS[Math.max(0, index)] ?? { name: 'Silver', min: 0 }
157 const nextTier = TIERS[index + 1]
158 const percent = nextTier ? Math.round(((points - tier.min) / (nextTier.min - tier.min)) * 100) : 100
159
160 return {
161 type: 'bubble',
162 header: box('vertical', [text('บัตรสมาชิก', { size: 'xs', color: '#ffffff' }), text(level || tier.name, { weight: 'bold', size: 'xl', color: '#ffd700', margin: 'md' })], {
163 backgroundColor: '#1a1a2e',
164 paddingAll: '20px',
165 }),
166 body: box('vertical', [
167 text(name, { weight: 'bold', size: 'md' }),
168 box('horizontal', [text('คะแนนสะสม', { size: 'xs', color: '#aaaaaa' }), text(`${points.toLocaleString('en-US')} แต้ม`, { size: 'sm', weight: 'bold', align: 'end' })], { margin: 'xl' }),
169 // progress bar = box สีเทา ข้างในมี box สีเข้มกว้างตามเปอร์เซ็นต์
170 box('vertical', [box('vertical', [], { backgroundColor: '#1a1a2e', height: '6px', width: `${percent}%`, cornerRadius: '3px' })], {
171 backgroundColor: '#eeeeee',
172 height: '6px',
173 cornerRadius: '3px',
174 margin: 'md',
175 }),
176 text(nextTier ? `อีก ${(nextTier.min - points).toLocaleString('en-US')} แต้มถึง ${nextTier.name}` : 'ระดับสูงสุดแล้ว 🎉', { size: 'xs', color: '#aaaaaa', margin: 'sm' }),
177 ]),
178 footer: box('horizontal', [button('ใช้คะแนน', 'ใช้คะแนน', { style: 'primary', color: '#1a1a2e' }), button('ประวัติ', 'ดูประวัติแต้ม', { style: 'secondary' })], { spacing: 'sm' }),
179 }
180}
181
182// ==========================================================================
183// บทที่ 3: แปลงข้อความที่พิมพ์เป็นข้อมูล
184// ==========================================================================
185// "ส้มตำ:60,ลาบ:70" -> [{ name: 'ส้มตำ', price: 60 }, { name: 'ลาบ', price: 70 }]
186function parseItems(raw: string): Item[] {
187 return raw
188 .split(',')
189 .map(part => {
190 const [name = '', price = '0'] = part.split(':')
191 return { name: name.trim(), price: Number(price.replace(/[^\d.]/g, '')) || 0 }
192 })
193 .filter(item => item.name)
194}
195
196// แยกคำที่อยู่ก่อนรายการ "a:1,b:2" ออกมาเป็นชื่อร้าน
197function splitNameAndItems(words: string[]): [string, string] {
198 const at = words.findIndex(w => w.includes(':'))
199 if (at < 0) return [words.join(' '), '']
200 return [words.slice(0, at).join(' '), words.slice(at).join(' ')]
201}
202
203// ==========================================================================
204// บทที่ 4: เก็บ Flex ที่สร้างไว้ใน $.store (จำข้าม session)
205// ==========================================================================
206let saved: SavedFlex[] = []
207let nextId = 0
208let previewId: number | undefined // Flex ที่กำลังแสดงในแผง
209let carouselIndex = 0
210let projectRoot = ''
211const PANE = 'flex-message-th'
212
213async function persist($: EngineInterface): Promise<void> {
214 await $.store.set('flexes', { saved, nextId })
215}
216
217async function saveFlex($: EngineInterface, name: string, altText: string, flex: FlexBubble | FlexCarousel): Promise<SavedFlex> {
218 const item: SavedFlex = { id: nextId++, name, altText: altText.slice(0, 400), flex, createdAt: await $.clock.now() }
219 saved = [...saved, item].slice(-30) // เก็บแค่ 30 อันล่าสุด
220 await persist($)
221 return item
222}
223
224function findFlex(arg: string): SavedFlex | undefined {
225 return saved.find(f => f.id === Number(arg))
226}
227
228// สิ่งที่ส่งเข้า LINE Messaging API จริง (ใส่ใน messages: [...])
229function toLineMessage(f: SavedFlex) {
230 return { type: 'flex', altText: f.altText, contents: f.flex }
231}
232
233function bubblesOf(f: SavedFlex): FlexBubble[] {
234 return f.flex.type === 'carousel' ? f.flex.contents : [f.flex]
235}
236
237// ==========================================================================
238// บทที่ 5: ตัวอย่างแบบข้อความ (ใช้ตอนเปิดแผงไม่ได้ เช่นรัน claude -p)
239// ==========================================================================
240function nodeToLines(node: FlexNode | undefined, indent = ''): string[] {
241 if (!node) return []
242 if (node.type === 'text') return [`${indent}${node.text ?? ''}`]
243 if (node.type === 'button') return [`${indent}[ ${node.action?.label ?? ''} ]`]
244 if (node.type === 'image') return [`${indent}🖼️ ${node.url ?? ''}`]
245 if (node.type === 'separator') return [`${indent}────────────`]
246 if (node.type !== 'box') return []
247 const children = node.contents ?? []
248 // แถวแนวนอนรวมเป็นบรรทัดเดียว
249 if (node.layout !== 'vertical') return [indent + children.flatMap(c => nodeToLines(c)).join(' ')]
250 return children.flatMap(c => nodeToLines(c, indent))
251}
252
253function bubbleToText(b: FlexBubble): string {
254 return [b.header, b.hero, b.body, b.footer].flatMap(part => nodeToLines(part, ' ')).join('\n')
255}
256
257// ==========================================================================
258// บทที่ 6: วาด Flex ในแผงด้วย element ของ Claude Code (แปลงทีละ node แบบ recursive)
259// ==========================================================================
260// สีแบบ 8 หลัก (#ffffffcc) ตัด alpha ออก ให้ทุกหน้าจอวาดได้
261function solid(color: string | undefined): string | undefined {
262 return color && /^#[0-9a-f]{6}/i.test(color) ? color.slice(0, 7) : undefined
263}
264
265export const register: Register = on => {
266 on('session.start', async ($, e, next) => {
267 projectRoot = e.cwd
268 const stored = (await $.store.get('flexes')) as { saved?: SavedFlex[]; nextId?: number } | undefined
269 saved = stored?.saved ?? []
270 nextId = stored?.nextId ?? 0
271 await $.command.register({ name: 'flex', description: 'สร้าง LINE Flex Message (/flex help)', argumentHint: '<template> ...' })
272 return next(e)
273 })
274
275 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
276 const { Box, Text, Button } = $.ui.resolve(e)
277 const current = previewId === undefined ? undefined : findFlex(String(previewId))
278 if (!current) return <Text dimColor>ยังไม่ได้เลือก Flex (/flex show <id>)</Text>
279
280 const bubbles = bubblesOf(current)
281 const index = Math.min(carouselIndex, bubbles.length - 1)
282 const bubble = bubbles[index]
283 if (!bubble) return <Text dimColor>Flex นี้ว่างเปล่า</Text>
284
285 // แปลง node ของ Flex เป็น element; path ใช้เป็น key ของปุ่มให้ไม่ซ้ำกัน
286 const draw = (node: FlexNode | undefined, path: string): JSX.Element | null => {
287 if (!node) return null
288 switch (node.type) {
289 case 'text':
290 return (
291 <Text color={solid(node.color)} bold={node.weight === 'bold'} strikethrough={node.decoration === 'line-through'} wrap="wrap">
292 {node.text ?? ''}
293 </Text>
294 )
295 case 'button': {
296 const action = node.action
297 return (
298 <Button
299 key={`btn-${path}`}
300 label={action?.label ?? 'ปุ่ม'}
301 variant={node.style === 'primary' ? 'primary' : undefined}
302 onPress={() => $.ui.toast(`💬 LINE จะส่ง: ${action?.text ?? action?.uri ?? ''}`)}
303 />
304 )
305 }
306 case 'image':
307 return <Text dimColor>🖼️ รูป {node.aspectRatio ?? ''}</Text>
308 case 'separator':
309 return <Text dimColor>────────────────</Text>
310 case 'box': {
311 const isRow = node.layout === 'horizontal' || node.layout === 'baseline'
312 const children = (node.contents ?? []).map((child, i) => draw(child, `${path}-${i}`))
313 // progress bar (box ว่างที่มีความกว้าง) วาดเป็นแถบตัวอักษร
314 if ((node.contents ?? []).length === 0 && node.width) {
315 const filled = Math.round((parseInt(node.width, 10) || 0) / 10)
316 return <Text color={solid(node.backgroundColor)}>{'█'.repeat(filled) + '░'.repeat(10 - filled)}</Text>
317 }
318 return (
319 <Box flexDirection={isRow ? 'row' : 'column'} gap={isRow ? 1 : 0} justifyContent={isRow ? 'space-between' : undefined} backgroundColor={solid(node.backgroundColor)} paddingX={node.backgroundColor ? 1 : 0}>
320 {children}
321 </Box>
322 )
323 }
324 default:
325 return null
326 }
327 }
328
329 return (
330 <Box flexDirection="column" gap={1}>
331 <Text dimColor>
332 #{current.id} {current.name}
333 {bubbles.length > 1 ? ` · ใบที่ ${index + 1}/${bubbles.length}` : ''}
334 </Text>
335 <Box flexDirection="column" borderStyle="round" borderColor={LINE_GREEN}>
336 {draw(bubble.header, 'h')}
337 {draw(bubble.hero, 'i')}
338 {draw(bubble.body, 'b')}
339 {draw(bubble.footer, 'f')}
340 </Box>
341 {bubbles.length > 1 && (
342 <Box gap={2}>
343 <Button key="prev" label="◀ ก่อนหน้า" onPress={() => {
344 carouselIndex = Math.max(0, index - 1)
345 $.ui.invalidate('ui.render')
346 }} />
347 <Button key="next" label="ถัดไป ▶" onPress={() => {
348 carouselIndex = Math.min(bubbles.length - 1, index + 1)
349 $.ui.invalidate('ui.render')
350 }} />
351 </Box>
352 )}
353 </Box>
354 )
355 })
356
357 // ==========================================================================
358 // บทที่ 7: คำสั่ง /flex
359 // ==========================================================================
360 on('command.run', { command: 'flex' }, async ($, e) => {
361 const words = e.args.trim().split(/\s+/).filter(Boolean)
362 const [sub = 'help', ...rest] = words
363
364 // สร้างแล้วเปิดแผงให้ดูทันที (ถ้าเปิดไม่ได้ แสดงเป็นข้อความแทน)
365 const created = async (f: SavedFlex) => {
366 previewId = f.id
367 carouselIndex = 0
368 const opened = await $.ui.open({ id: PANE, title: '💚 Flex Preview' })
369 $.ui.invalidate('ui.render')
370 const preview = opened.isPlaced ? '' : `\n${bubblesOf(f).map(bubbleToText).join('\n ──── ใบถัดไป ────\n')}`
371 return { text: `💚 สร้าง Flex #${f.id} ${f.name} แล้ว (/flex json ${f.id} เพื่อเอา JSON)${preview}` }
372 }
373
374 switch (sub) {
375 case 'product': {
376 // /flex product เสื้อยืดลายไทย 299 [ราคาเดิม] [https://รูป]
377 const url = rest.find(w => /^https:\/\//.test(w))
378 const numbers = rest.filter(w => /^\d+(\.\d+)?$/.test(w)).map(Number)
379 const name = rest.filter(w => w !== url && !/^\d+(\.\d+)?$/.test(w)).join(' ')
380 if (!name || numbers[0] === undefined) return { text: 'วิธีใช้: /flex product <ชื่อ> <ราคา> [ราคาเดิม] [https://รูป]' }
381 return created(await saveFlex($, `สินค้า ${name}`, `${name} ราคา ${baht(numbers[0])}`, productBubble(name, numbers[0], numbers[1], url)))
382 }
383 case 'receipt': {
384 // /flex receipt ร้านส้มตำ ส้มตำ:60,ลาบ:70 หรือ /flex receipt ร้านส้มตำ 350
385 const [shop, rawItems] = splitNameAndItems(rest)
386 let items = parseItems(rawItems)
387 if (items.length === 0) {
388 const total = Number(rest.at(-1))
389 if (!total) return { text: 'วิธีใช้: /flex receipt <ร้าน> <รายการ:ราคา,...> หรือ /flex receipt <ร้าน> <ยอดรวม>' }
390 items = [{ name: 'ยอดชำระ', price: total }]
391 const shopName = rest.slice(0, -1).join(' ')
392 return created(await saveFlex($, `ใบเสร็จ ${shopName}`, `ใบเสร็จ ${shopName} ${baht(total)}`, receiptBubble(shopName, items)))
393 }
394 const total = items.reduce((s, i) => s + i.price, 0)
395 return created(await saveFlex($, `ใบเสร็จ ${shop}`, `ใบเสร็จ ${shop} ${baht(total)}`, receiptBubble(shop, items)))
396 }
397 case 'profile': {
398 // /flex profile สมชาย ใจดี | นักพัฒนา Full-Stack
399 const joined = rest.join(' ')
400 const [name = '', role = ''] = joined.includes('|') ? joined.split('|').map(s => s.trim()) : [rest[0] ?? '', rest.slice(1).join(' ')]
401 if (!name) return { text: 'วิธีใช้: /flex profile <ชื่อ> | <ตำแหน่ง>' }
402 return created(await saveFlex($, `โปรไฟล์ ${name}`, `โปรไฟล์ ${name}`, profileBubble(name, role)))
403 }
404 case 'menu': {
405 // /flex menu ร้านป้าแดง ส้มตำ:60,ลาบ:70,ข้าวเหนียว:20
406 const [restaurant, rawItems] = splitNameAndItems(rest)
407 const dishes = parseItems(rawItems)
408 if (!restaurant || dishes.length === 0) return { text: 'วิธีใช้: /flex menu <ร้าน> <เมนู:ราคา,เมนู:ราคา>' }
409 return created(await saveFlex($, `เมนู ${restaurant}`, `เมนูร้าน ${restaurant}`, menuBubble(restaurant, dishes)))
410 }
411 case 'member': {
412 // /flex member สมชาย 1250 [Gold]
413 const at = rest.findIndex(w => /^\d+$/.test(w))
414 if (at <= 0) return { text: 'วิธีใช้: /flex member <ชื่อ> <แต้ม> [ระดับ]' }
415 const name = rest.slice(0, at).join(' ')
416 const points = Number(rest[at])
417 return created(await saveFlex($, `สมาชิก ${name}`, `บัตรสมาชิก ${name} ${points} แต้ม`, memberBubble(name, points, rest.slice(at + 1).join(' ') || undefined)))
418 }
419 case 'carousel': {
420 // /flex carousel เสื้อยืด:299,กางเกง:459,หมวก:199
421 const items = parseItems(rest.join(' '))
422 if (items.length < 2) return { text: 'วิธีใช้: /flex carousel <ชื่อ:ราคา,ชื่อ:ราคา,...> (อย่างน้อย 2 ชิ้น สูงสุด 12)' }
423 const flex: FlexCarousel = { type: 'carousel', contents: items.slice(0, 12).map(i => productBubble(i.name, i.price)) }
424 return created(await saveFlex($, `Carousel ${items.length} ชิ้น`, `สินค้า ${items.map(i => i.name).join(', ')}`, flex))
425 }
426 case 'demo': {
427 await saveFlex($, 'สินค้า เสื้อยืดลายไทย', 'เสื้อยืดลายไทย ราคา ฿299', productBubble('เสื้อยืดลายไทย', 299, 399))
428 await saveFlex($, 'ใบเสร็จ ร้านส้มตำป้าแดง', 'ใบเสร็จ ร้านส้มตำป้าแดง ฿150', receiptBubble('ร้านส้มตำป้าแดง', parseItems('ส้มตำไทย:60,ลาบหมู:70,ข้าวเหนียว:20')))
429 await saveFlex($, 'โปรไฟล์ สมชาย ใจดี', 'โปรไฟล์ สมชาย ใจดี', profileBubble('สมชาย ใจดี', 'นักพัฒนา Full-Stack'))
430 await saveFlex($, 'เมนู ร้านป้าแดง', 'เมนูร้านป้าแดง', menuBubble('ร้านป้าแดง', parseItems('ส้มตำ:60,ลาบ:70,ข้าวเหนียว:20,ไก่ย่าง:120')))
431 await saveFlex($, 'สมาชิก สมชาย', 'บัตรสมาชิก สมชาย 1250 แต้ม', memberBubble('สมชาย', 1250))
432 const last = await saveFlex($, 'Carousel 3 ชิ้น', 'สินค้า เสื้อยืด, กางเกง, หมวก', {
433 type: 'carousel',
434 contents: parseItems('เสื้อยืด:299,กางเกง:459,หมวก:199').map(i => productBubble(i.name, i.price)),
435 })
436 const result = await created(last)
437 return { text: `🎨 สร้างตัวอย่าง 6 แบบแล้ว (#${last.id - 5} ถึง #${last.id}) ดูรายการด้วย /flex list\n${result.text}` }
438 }
439 case 'list': {
440 if (saved.length === 0) return { text: 'ยังไม่มี Flex ลอง /flex demo' }
441 return { text: ['💚 Flex ที่สร้างไว้', ...saved.map(f => ` #${f.id} ${f.name} (${f.flex.type})`)].join('\n') }
442 }
443 case 'show': {
444 const f = findFlex(rest[0] ?? '')
445 if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''} (ดูรายการด้วย /flex list)` }
446 return created(f)
447 }
448 case 'json': {
449 const f = findFlex(rest[0] ?? '')
450 if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''}` }
451 return { text: `JSON ของ Flex #${f.id} (ใส่ใน messages: [...] ของ LINE Messaging API)\n\`\`\`json\n${JSON.stringify(toLineMessage(f), null, 2)}\n\`\`\`` }
452 }
453 case 'export': {
454 const f = findFlex(rest[0] ?? '')
455 if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''}` }
456 const sep = projectRoot.includes('\\') ? '\\' : '/'
457 const path = `${projectRoot}${sep}flex-${f.id}.json`
458 await $.fs.write(path, JSON.stringify(toLineMessage(f), null, 2) + '\n')
459 return { text: `💾 บันทึกแล้ว: ${path}` }
460 }
461 case 'delete': {
462 const f = findFlex(rest[0] ?? '')
463 if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''}` }
464 saved = saved.filter(x => x !== f)
465 await persist($)
466 if (previewId === f.id) {
467 previewId = undefined
468 $.ui.invalidate('ui.render')
469 }
470 return { text: `🗑️ ลบ Flex #${f.id} แล้ว` }
471 }
472 case 'clear':
473 saved = []
474 previewId = undefined
475 await persist($)
476 $.ui.invalidate('ui.render')
477 return { text: '🗑️ ลบ Flex ทั้งหมดแล้ว' }
478 default:
479 return {
480 text: `💚 Flex Message TH (Mod 12)
481========================================
482สร้าง:
483 /flex product <ชื่อ> <ราคา> [ราคาเดิม] [https://รูป]
484 /flex receipt <ร้าน> <รายการ:ราคา,...> หรือ <ร้าน> <ยอดรวม>
485 /flex profile <ชื่อ> | <ตำแหน่ง>
486 /flex menu <ร้าน> <เมนู:ราคา,เมนู:ราคา>
487 /flex member <ชื่อ> <แต้ม> [ระดับ]
488 /flex carousel <ชื่อ:ราคา,ชื่อ:ราคา,...>
489 /flex demo สร้างตัวอย่างครบ 6 แบบ
490
491จัดการ:
492 /flex list | show <id> | json <id> | export <id> | delete <id> | clear`,
493 }
494 }
495 })
496}
497
498/**
499 * ========================================================================
500 * การบ้าน / ต่อยอด
501 * ========================================================================
502 * 1. ส่งเข้า LINE จริง: เพิ่ม userConfig (sensitive) สำหรับ Channel Access Token แล้วใช้
503 * $.http.fetch("https://api.line.me/v2/bot/message/push", { method: "POST",
504 * headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
505 * body: JSON.stringify({ to: userId, messages: [toLineMessage(f)] }) })
506 * 2. ให้ Claude สร้าง Flex จากคำบรรยาย: $.prompt.submit("สร้าง Flex เมนูร้านกาแฟ ...") แล้วตรวจ JSON ที่ได้
507 * 3. ตรวจ JSON กับ Flex Message Simulator ของ LINE ก่อนส่งจริง
508 */
509