@pplethai/cli 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1 -1
- package/package.json +1 -1
- package/skills/pple-platform/SKILL.md +10 -7
- package/skills/pple-platform/evals/evals.json +2 -2
- package/skills/pple-platform/references/design-system.md +7 -2
- package/skills/pple-platform/references/mini-app.md +67 -31
- package/skills/pple-platform/references/project-scaffold.md +1 -1
- package/templates/golden-path/CLAUDE.md +7 -3
- package/templates/golden-path/README.md +5 -2
- package/templates/golden-path/package.json +1 -0
- package/templates/golden-path/shared/api.ts +84 -3
- package/templates/golden-path/src/index.ts +14 -3
- package/templates/golden-path/src/notify.ts +152 -24
- package/templates/golden-path/test/harness.ts +2 -2
- package/templates/golden-path/test/smoke.test.ts +242 -16
- package/templates/golden-path/web/App.tsx +264 -54
- package/templates/golden-path/web/app.css +13 -22
- package/templates/golden-path/web/main.tsx +1 -1
- package/templates/golden-path/web/pple.ts +13 -2
|
@@ -6,15 +6,35 @@ import {
|
|
|
6
6
|
CardDescription,
|
|
7
7
|
CardHeader,
|
|
8
8
|
CardTitle,
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuItem,
|
|
12
|
+
DropdownMenuLabel,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
Icon,
|
|
9
16
|
Input,
|
|
10
|
-
|
|
17
|
+
Navbar,
|
|
11
18
|
Spinner,
|
|
19
|
+
Tabs,
|
|
20
|
+
TabsContent,
|
|
21
|
+
TabsList,
|
|
22
|
+
TabsTrigger,
|
|
12
23
|
Textarea,
|
|
13
24
|
showToast,
|
|
14
25
|
} from "@pplethai/components";
|
|
26
|
+
import { ChevronDown, LogOut, Plus, Send, Upload, User } from "lucide-react";
|
|
15
27
|
import { useEffect, useRef, useState } from "react";
|
|
16
|
-
import type {
|
|
17
|
-
|
|
28
|
+
import type {
|
|
29
|
+
Me,
|
|
30
|
+
Note,
|
|
31
|
+
NotificationAudience,
|
|
32
|
+
NotificationDelivery,
|
|
33
|
+
NotificationRecipient,
|
|
34
|
+
UploadedFile,
|
|
35
|
+
} from "../shared/api";
|
|
36
|
+
import { MAX_DIRECT_RECIPIENTS } from "../shared/api";
|
|
37
|
+
import { api, apiJson, logout } from "./pple";
|
|
18
38
|
|
|
19
39
|
function bytes(n: number): string {
|
|
20
40
|
if (n < 1024) return `${n} B`;
|
|
@@ -26,35 +46,89 @@ function message(e: unknown): string {
|
|
|
26
46
|
return e instanceof Error ? e.message : String(e);
|
|
27
47
|
}
|
|
28
48
|
|
|
49
|
+
// Starter nav — replace with your routes. With a client router, pass
|
|
50
|
+
// renderHomeLink / renderLink (see pple-erp's Navbar wrapper).
|
|
51
|
+
const NAV_ITEMS = [{ href: "/", label: "หน้าหลัก", end: true }];
|
|
52
|
+
|
|
29
53
|
export function App() {
|
|
30
54
|
const [me, setMe] = useState<Me | null>(null);
|
|
31
55
|
|
|
32
56
|
useEffect(() => {
|
|
33
57
|
apiJson<Me>("/api/me").then(setMe).catch((e) => showToast({
|
|
34
|
-
title: "
|
|
58
|
+
title: "โหลดโปรไฟล์ไม่สำเร็จ",
|
|
35
59
|
description: message(e),
|
|
36
60
|
variant: "error",
|
|
37
61
|
}));
|
|
38
62
|
}, []);
|
|
39
63
|
|
|
64
|
+
const displayName = me?.actor.name ?? me?.actor.sub ?? "";
|
|
65
|
+
const roleLabels = me
|
|
66
|
+
? [me.effective.activeRole, ...me.effective.extraRoles]
|
|
67
|
+
: [];
|
|
68
|
+
|
|
40
69
|
return (
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
|
|
70
|
+
<>
|
|
71
|
+
<Navbar
|
|
72
|
+
title="PPLE App"
|
|
73
|
+
pathname={window.location.pathname}
|
|
74
|
+
items={NAV_ITEMS}
|
|
75
|
+
mobileMenuAriaLabel={{ open: "เปิดเมนู", close: "ปิดเมนู" }}
|
|
76
|
+
navAriaLabel="เมนูหลัก"
|
|
77
|
+
>
|
|
78
|
+
{me && (
|
|
79
|
+
<DropdownMenu>
|
|
80
|
+
<DropdownMenuTrigger asChild>
|
|
81
|
+
<Button variant="outline" size="default" className="gap-2">
|
|
82
|
+
<Icon icon={User} />
|
|
83
|
+
<span>{displayName}</span>
|
|
84
|
+
<Icon icon={ChevronDown} size="sm" />
|
|
85
|
+
</Button>
|
|
86
|
+
</DropdownMenuTrigger>
|
|
87
|
+
<DropdownMenuContent align="end" className="w-64">
|
|
88
|
+
<DropdownMenuLabel>
|
|
89
|
+
<div className="font-medium">{displayName}</div>
|
|
90
|
+
{me.actor.mobile && (
|
|
91
|
+
<div className="text-xs text-muted-foreground font-normal">
|
|
92
|
+
{me.actor.mobile}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
{me.effective.isDelegated && (
|
|
96
|
+
<div className="text-xs text-muted-foreground font-normal">
|
|
97
|
+
(ปฏิบัติงานแทน — สิทธิ์เป็นของหัวหน้า)
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</DropdownMenuLabel>
|
|
101
|
+
<DropdownMenuSeparator />
|
|
102
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
|
103
|
+
บทบาทของคุณ
|
|
104
|
+
</DropdownMenuLabel>
|
|
105
|
+
{roleLabels.map((label) => (
|
|
106
|
+
<DropdownMenuItem key={label} disabled className="opacity-70">
|
|
107
|
+
{label}
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
))}
|
|
110
|
+
<DropdownMenuSeparator />
|
|
111
|
+
<DropdownMenuItem onClick={() => void logout()}>
|
|
112
|
+
<Icon icon={LogOut} />
|
|
113
|
+
ออกจากระบบ
|
|
114
|
+
</DropdownMenuItem>
|
|
115
|
+
</DropdownMenuContent>
|
|
116
|
+
</DropdownMenu>
|
|
117
|
+
)}
|
|
118
|
+
</Navbar>
|
|
47
119
|
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
120
|
+
<main className="screen">
|
|
121
|
+
<div className="stack">
|
|
122
|
+
<p className="muted">
|
|
123
|
+
นี่คือเทมเพลตเริ่มต้น แต่ละการ์ดด้านล่างแสดงความสามารถหนึ่งอย่างของ
|
|
124
|
+
แพลตฟอร์ม แทนที่ด้วยแอปของคุณเองได้เลย
|
|
125
|
+
</p>
|
|
126
|
+
<NotesCard />
|
|
127
|
+
<FilesCard />
|
|
128
|
+
<NotifyCard />
|
|
129
|
+
</div>
|
|
130
|
+
</main>
|
|
131
|
+
</>
|
|
58
132
|
);
|
|
59
133
|
}
|
|
60
134
|
|
|
@@ -68,7 +142,7 @@ function NotesCard() {
|
|
|
68
142
|
apiJson<{ notes: Note[] }>("/api/notes")
|
|
69
143
|
.then((r) => setNotes(r.notes))
|
|
70
144
|
.catch((e) => {
|
|
71
|
-
showToast({ title: "
|
|
145
|
+
showToast({ title: "โหลดบันทึกไม่สำเร็จ", description: message(e), variant: "error" });
|
|
72
146
|
// Land on the empty state. Leaving this null would spin forever.
|
|
73
147
|
setNotes([]);
|
|
74
148
|
});
|
|
@@ -89,7 +163,7 @@ function NotesCard() {
|
|
|
89
163
|
setBody("");
|
|
90
164
|
await load();
|
|
91
165
|
} catch (e) {
|
|
92
|
-
showToast({ title: "
|
|
166
|
+
showToast({ title: "บันทึกไม่สำเร็จ", description: message(e), variant: "error" });
|
|
93
167
|
} finally {
|
|
94
168
|
setSaving(false);
|
|
95
169
|
}
|
|
@@ -98,32 +172,35 @@ function NotesCard() {
|
|
|
98
172
|
return (
|
|
99
173
|
<Card className="card">
|
|
100
174
|
<CardHeader>
|
|
101
|
-
<CardTitle
|
|
102
|
-
<CardDescription
|
|
175
|
+
<CardTitle>บันทึก</CardTitle>
|
|
176
|
+
<CardDescription>อ่านและเขียนฐานข้อมูล D1 ของแอป</CardDescription>
|
|
103
177
|
</CardHeader>
|
|
104
178
|
<CardContent>
|
|
105
179
|
<div className="stack">
|
|
106
180
|
<div className="row">
|
|
107
181
|
<Input
|
|
108
182
|
value={body}
|
|
109
|
-
placeholder="
|
|
183
|
+
placeholder="เขียนบันทึก…"
|
|
110
184
|
onChange={(e) => setBody(e.target.value)}
|
|
111
185
|
onKeyDown={(e) => e.key === "Enter" && add()}
|
|
112
186
|
/>
|
|
113
187
|
<Button onClick={add} disabled={saving || !body.trim()}>
|
|
114
|
-
{saving ? <Spinner /> :
|
|
188
|
+
{saving ? <Spinner /> : <Icon icon={Plus} />}
|
|
189
|
+
เพิ่ม
|
|
115
190
|
</Button>
|
|
116
191
|
</div>
|
|
117
192
|
{notes === null ? (
|
|
118
193
|
<Spinner />
|
|
119
194
|
) : notes.length === 0 ? (
|
|
120
|
-
<p className="muted"
|
|
195
|
+
<p className="muted">ยังไม่มีบันทึก</p>
|
|
121
196
|
) : (
|
|
122
197
|
<ul className="list">
|
|
123
198
|
{notes.map((n) => (
|
|
124
199
|
<li key={n.id} className="list__item">
|
|
125
200
|
<span>{n.body}</span>
|
|
126
|
-
<span className="muted">
|
|
201
|
+
<span className="muted">
|
|
202
|
+
{new Date(n.createdAt).toLocaleString("th-TH")}
|
|
203
|
+
</span>
|
|
127
204
|
</li>
|
|
128
205
|
))}
|
|
129
206
|
</ul>
|
|
@@ -144,7 +221,7 @@ function FilesCard() {
|
|
|
144
221
|
apiJson<{ files: UploadedFile[] }>("/api/files")
|
|
145
222
|
.then((r) => setFiles(r.files))
|
|
146
223
|
.catch((e) => {
|
|
147
|
-
showToast({ title: "
|
|
224
|
+
showToast({ title: "โหลดไฟล์ไม่สำเร็จ", description: message(e), variant: "error" });
|
|
148
225
|
// Land on the empty state. Leaving this null would spin forever.
|
|
149
226
|
setFiles([]);
|
|
150
227
|
});
|
|
@@ -159,11 +236,11 @@ function FilesCard() {
|
|
|
159
236
|
const fd = new FormData();
|
|
160
237
|
fd.append("file", file);
|
|
161
238
|
const res = await api("/api/files", { method: "POST", body: fd });
|
|
162
|
-
if (!res.ok) throw new Error(
|
|
239
|
+
if (!res.ok) throw new Error(`อัปโหลดไม่สำเร็จ (${res.status})`);
|
|
163
240
|
if (inputRef.current) inputRef.current.value = "";
|
|
164
241
|
await load();
|
|
165
242
|
} catch (e) {
|
|
166
|
-
showToast({ title: "
|
|
243
|
+
showToast({ title: "อัปโหลดไฟล์ไม่สำเร็จ", description: message(e), variant: "error" });
|
|
167
244
|
} finally {
|
|
168
245
|
setUploading(false);
|
|
169
246
|
}
|
|
@@ -172,27 +249,34 @@ function FilesCard() {
|
|
|
172
249
|
return (
|
|
173
250
|
<Card className="card">
|
|
174
251
|
<CardHeader>
|
|
175
|
-
<CardTitle
|
|
176
|
-
<CardDescription
|
|
252
|
+
<CardTitle>ไฟล์</CardTitle>
|
|
253
|
+
<CardDescription>อัปโหลดไปยังบัคเก็ต R2 ของแอป</CardDescription>
|
|
177
254
|
</CardHeader>
|
|
178
255
|
<CardContent>
|
|
179
256
|
<div className="stack">
|
|
180
257
|
<div className="row">
|
|
181
|
-
{/*
|
|
182
|
-
a concurrent upload. */}
|
|
258
|
+
{/* Hidden native picker; the design-system Button opens it. Disabled
|
|
259
|
+
while uploading so a second pick can't start a concurrent upload. */}
|
|
183
260
|
<input
|
|
184
261
|
ref={inputRef}
|
|
185
262
|
type="file"
|
|
186
|
-
className="
|
|
263
|
+
className="hidden"
|
|
187
264
|
onChange={upload}
|
|
188
265
|
disabled={uploading}
|
|
189
266
|
/>
|
|
190
|
-
|
|
267
|
+
<Button
|
|
268
|
+
variant="outline"
|
|
269
|
+
disabled={uploading}
|
|
270
|
+
onClick={() => inputRef.current?.click()}
|
|
271
|
+
>
|
|
272
|
+
{uploading ? <Spinner /> : <Icon icon={Upload} />}
|
|
273
|
+
เลือกไฟล์
|
|
274
|
+
</Button>
|
|
191
275
|
</div>
|
|
192
276
|
{files === null ? (
|
|
193
277
|
<Spinner />
|
|
194
278
|
) : files.length === 0 ? (
|
|
195
|
-
<p className="muted"
|
|
279
|
+
<p className="muted">ยังไม่มีไฟล์</p>
|
|
196
280
|
) : (
|
|
197
281
|
<ul className="list">
|
|
198
282
|
{files.map((f) => (
|
|
@@ -209,37 +293,101 @@ function FilesCard() {
|
|
|
209
293
|
);
|
|
210
294
|
}
|
|
211
295
|
|
|
212
|
-
// ---
|
|
296
|
+
// --- Notifications: Broadcast and Direct ----------------------------------
|
|
297
|
+
//
|
|
298
|
+
// Two modes, one card. The audience is always stated — there is no way to
|
|
299
|
+
// start a send from this UI without choosing one, which mirrors the schema in
|
|
300
|
+
// shared/api.ts, where leaving it out is a compile error.
|
|
301
|
+
|
|
302
|
+
/** Builder-facing Thai for every refusal this card can receive. */
|
|
303
|
+
const NOTIFY_ERRORS: Record<string, string> = {
|
|
304
|
+
// This app's own Zod check (src/index.ts), before anything is sent.
|
|
305
|
+
invalid_body: `ข้อมูลไม่ครบหรือไม่ถูกต้อง — ต้องมีหัวข้อ ข้อความ และกลุ่มผู้รับ ไม่เกิน ${MAX_DIRECT_RECIPIENTS} คน`,
|
|
306
|
+
invalid_request: "คำขอไม่ถูกต้อง — ตรวจสอบว่าระบุกลุ่มผู้รับแล้ว",
|
|
307
|
+
invalid_recipients: `รายชื่อผู้รับไม่ถูกต้อง — ต้องมีอย่างน้อยหนึ่งคน ไม่เกิน ${MAX_DIRECT_RECIPIENTS} คน และหนึ่งบรรทัดระบุได้อย่างเดียวระหว่าง sub กับเบอร์โทรศัพท์`,
|
|
308
|
+
idempotency_key_conflict: "คีย์กันส่งซ้ำถูกใช้กับรายชื่อจำนวนอื่นไปแล้ว",
|
|
309
|
+
quota_exceeded: "โควตาการแจ้งเตือนหมด — ยังไม่มีใครได้รับข้อความนี้ ลองใหม่หลังโควตารีเซ็ต",
|
|
310
|
+
not_permitted: "คีย์การแจ้งเตือนนี้ไม่มีสิทธิ์ส่งผ่านช่องทางนี้",
|
|
311
|
+
send_failed: "ส่งไม่สำเร็จ",
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* One entry per line (commas work too). An entry that is all digits — with or
|
|
316
|
+
* without a leading `+` — is sent as a mobile number; anything else as a PPLE
|
|
317
|
+
* ID `sub`. Never both in one entry: the platform refuses those.
|
|
318
|
+
*/
|
|
319
|
+
function parseRecipients(text: string): NotificationRecipient[] {
|
|
320
|
+
return text
|
|
321
|
+
.split(/[\n,]/)
|
|
322
|
+
.map((entry) => entry.trim())
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.map((entry) =>
|
|
325
|
+
/^\+?[\d\s-]+$/.test(entry)
|
|
326
|
+
? { phone: entry.replace(/[\s-]/g, "") }
|
|
327
|
+
: { sub: entry },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function recipientLabel(r: NotificationRecipient): string {
|
|
332
|
+
return "sub" in r ? r.sub : r.phone;
|
|
333
|
+
}
|
|
334
|
+
|
|
213
335
|
function NotifyCard() {
|
|
336
|
+
const [kind, setKind] = useState<NotificationAudience["kind"]>("all");
|
|
337
|
+
const [recipientsText, setRecipientsText] = useState("");
|
|
214
338
|
const [title, setTitle] = useState("");
|
|
215
339
|
const [body, setBody] = useState("");
|
|
216
340
|
const [sending, setSending] = useState(false);
|
|
341
|
+
const [results, setResults] = useState<NotificationDelivery[] | null>(null);
|
|
342
|
+
|
|
343
|
+
const recipients = parseRecipients(recipientsText);
|
|
344
|
+
// The cap is the platform's, and going over it is a refusal rather than a
|
|
345
|
+
// truncation — so hold the send here and say so, rather than letting a
|
|
346
|
+
// Builder discover it from an error toast.
|
|
347
|
+
const tooMany = recipients.length > MAX_DIRECT_RECIPIENTS;
|
|
348
|
+
const ready =
|
|
349
|
+
!!title.trim() &&
|
|
350
|
+
!!body.trim() &&
|
|
351
|
+
(kind === "all" || (recipients.length > 0 && !tooMany));
|
|
217
352
|
|
|
218
353
|
async function send() {
|
|
219
|
-
if (!
|
|
354
|
+
if (!ready) return;
|
|
220
355
|
setSending(true);
|
|
356
|
+
setResults(null);
|
|
221
357
|
try {
|
|
358
|
+
// The audience travels with the send, always. Choosing it is the first
|
|
359
|
+
// thing this form does, not an afterthought a dropped field can undo.
|
|
360
|
+
const audience: NotificationAudience =
|
|
361
|
+
kind === "all" ? { kind: "all" } : { kind: "direct", recipients };
|
|
222
362
|
const res = await api("/api/notify", {
|
|
223
363
|
method: "POST",
|
|
224
364
|
headers: { "content-type": "application/json" },
|
|
225
|
-
body: JSON.stringify({ title, body }),
|
|
365
|
+
body: JSON.stringify({ audience, title, body }),
|
|
226
366
|
});
|
|
227
|
-
const data = (await res.json().catch(() => ({}))) as {
|
|
367
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
368
|
+
error?: string;
|
|
369
|
+
results?: NotificationDelivery[];
|
|
370
|
+
};
|
|
228
371
|
if (res.ok) {
|
|
229
|
-
showToast({ title: "
|
|
372
|
+
showToast({ title: "ส่งการแจ้งเตือนแล้ว", variant: "success" });
|
|
373
|
+
setResults(data.results ?? null);
|
|
230
374
|
setTitle("");
|
|
231
375
|
setBody("");
|
|
232
376
|
} else if (data.error === "notifications_not_configured") {
|
|
233
377
|
showToast({
|
|
234
|
-
title: "
|
|
235
|
-
description: "
|
|
378
|
+
title: "ยังไม่ได้ตั้งค่าการแจ้งเตือน",
|
|
379
|
+
description: "ตั้งค่า NOTIFY_API_BASE และ NOTIFY_API_KEY เพื่อส่งจริง",
|
|
236
380
|
variant: "warning",
|
|
237
381
|
});
|
|
238
382
|
} else {
|
|
239
|
-
showToast({
|
|
383
|
+
showToast({
|
|
384
|
+
title: "ส่งไม่สำเร็จ",
|
|
385
|
+
description: NOTIFY_ERRORS[data.error ?? ""] ?? data.error,
|
|
386
|
+
variant: "error",
|
|
387
|
+
});
|
|
240
388
|
}
|
|
241
389
|
} catch (e) {
|
|
242
|
-
showToast({ title: "
|
|
390
|
+
showToast({ title: "ส่งไม่สำเร็จ", description: message(e), variant: "error" });
|
|
243
391
|
} finally {
|
|
244
392
|
setSending(false);
|
|
245
393
|
}
|
|
@@ -248,19 +396,81 @@ function NotifyCard() {
|
|
|
248
396
|
return (
|
|
249
397
|
<Card className="card">
|
|
250
398
|
<CardHeader>
|
|
251
|
-
<CardTitle
|
|
399
|
+
<CardTitle>แจ้งเตือนผู้ใช้</CardTitle>
|
|
252
400
|
<CardDescription>
|
|
253
|
-
|
|
254
|
-
|
|
401
|
+
ส่งถึงผู้ใช้ของแอปนี้ — ทุกคน หรือระบุรายบุคคลด้วย PPLE ID (sub)
|
|
402
|
+
หรือเบอร์โทรศัพท์ การระบุชื่อเป็นการ<em>จำกัด</em>ผู้รับให้แคบลงเท่านั้น
|
|
403
|
+
ไม่เคยขยายออก คนที่ยังไม่เคยเปิดแอปนี้จะไม่ได้รับ
|
|
255
404
|
</CardDescription>
|
|
256
405
|
</CardHeader>
|
|
257
406
|
<CardContent>
|
|
258
407
|
<div className="stack">
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
408
|
+
<Tabs
|
|
409
|
+
value={kind}
|
|
410
|
+
onValueChange={(v) => {
|
|
411
|
+
setKind(v as NotificationAudience["kind"]);
|
|
412
|
+
setResults(null);
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
<TabsList>
|
|
416
|
+
<TabsTrigger value="all">การแจ้งเตือนทุกคน</TabsTrigger>
|
|
417
|
+
<TabsTrigger value="direct">การแจ้งเตือนรายบุคคล</TabsTrigger>
|
|
418
|
+
</TabsList>
|
|
419
|
+
<TabsContent value="all">
|
|
420
|
+
<p className="muted">
|
|
421
|
+
ส่งถึงผู้ใช้แอปนี้ทุกคน — นับโควตาตามจำนวนผู้รับทั้งหมด
|
|
422
|
+
</p>
|
|
423
|
+
</TabsContent>
|
|
424
|
+
<TabsContent value="direct">
|
|
425
|
+
<div className="stack">
|
|
426
|
+
<Textarea
|
|
427
|
+
value={recipientsText}
|
|
428
|
+
placeholder={"บรรทัดละหนึ่งคน\n0812345678\nu-42"}
|
|
429
|
+
onChange={(e) => setRecipientsText(e.target.value)}
|
|
430
|
+
/>
|
|
431
|
+
<p className="muted">
|
|
432
|
+
ระบุได้สูงสุด {MAX_DIRECT_RECIPIENTS} คนต่อครั้ง (ตอนนี้{" "}
|
|
433
|
+
{recipients.length} คน{tooMany ? " — เกินจำนวน ต้องแบ่งส่งเป็นรอบ" : ""})
|
|
434
|
+
— ทุกชื่อที่ระบุถูกนับโควตา แม้จะส่งไม่ถึง
|
|
435
|
+
จึงควรตัดรายชื่อที่ส่งไม่ถึงออก
|
|
436
|
+
</p>
|
|
437
|
+
</div>
|
|
438
|
+
</TabsContent>
|
|
439
|
+
</Tabs>
|
|
440
|
+
<Input
|
|
441
|
+
value={title}
|
|
442
|
+
placeholder="หัวข้อ"
|
|
443
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
444
|
+
/>
|
|
445
|
+
<Textarea
|
|
446
|
+
value={body}
|
|
447
|
+
placeholder="ข้อความ"
|
|
448
|
+
onChange={(e) => setBody(e.target.value)}
|
|
449
|
+
/>
|
|
450
|
+
<Button onClick={send} disabled={sending || !ready}>
|
|
451
|
+
{sending ? <Spinner /> : <Icon icon={Send} />}
|
|
452
|
+
ส่งการแจ้งเตือน
|
|
263
453
|
</Button>
|
|
454
|
+
{results && (
|
|
455
|
+
<div className="stack">
|
|
456
|
+
<ul className="list">
|
|
457
|
+
{results.map((r, i) => (
|
|
458
|
+
<li key={`${recipientLabel(r.recipient)}-${i}`} className="list__item">
|
|
459
|
+
<span>{recipientLabel(r.recipient)}</span>
|
|
460
|
+
<Badge variant={r.status === "delivered" ? "default" : "outline"}>
|
|
461
|
+
{r.status === "delivered" ? "ส่งแล้ว" : "ส่งไม่ถึง"}
|
|
462
|
+
</Badge>
|
|
463
|
+
</li>
|
|
464
|
+
))}
|
|
465
|
+
</ul>
|
|
466
|
+
{/* One collapsed reason, on purpose: the app is never told which
|
|
467
|
+
of these applies (docs/adr/0017). Do not guess in the UI. */}
|
|
468
|
+
<p className="muted">
|
|
469
|
+
“ส่งไม่ถึง” หมายถึงยังไม่เคยเปิดแอปนี้ อยู่นอกกลุ่มผู้รับ
|
|
470
|
+
ไม่มีบัญชี PPLE ID หรือปิดรับการแจ้งเตือน — ระบบไม่ระบุว่าเป็นข้อใด
|
|
471
|
+
</p>
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
264
474
|
</div>
|
|
265
475
|
</CardContent>
|
|
266
476
|
</Card>
|
|
@@ -17,34 +17,23 @@ body {
|
|
|
17
17
|
max-width: 640px;
|
|
18
18
|
margin: 0 auto;
|
|
19
19
|
padding: 16px;
|
|
20
|
+
/* Navbar is position:fixed below the md breakpoint; give content room. */
|
|
21
|
+
padding-top: 4.5rem;
|
|
20
22
|
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
|
21
23
|
min-height: 100vh;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
@media (min-width: 768px) {
|
|
27
|
+
.screen {
|
|
28
|
+
padding-top: 16px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
.screen--center {
|
|
25
33
|
display: grid;
|
|
26
34
|
place-items: center;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
.appbar {
|
|
30
|
-
display: flex;
|
|
31
|
-
align-items: center;
|
|
32
|
-
gap: 12px;
|
|
33
|
-
padding: 8px 4px 16px;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
.appbar__logo {
|
|
37
|
-
height: 28px;
|
|
38
|
-
width: auto;
|
|
39
|
-
color: #ff6a13; /* brand primary */
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.appbar__title {
|
|
43
|
-
font-weight: 600;
|
|
44
|
-
font-size: 18px;
|
|
45
|
-
margin-right: auto;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
37
|
.stack {
|
|
49
38
|
display: flex;
|
|
50
39
|
flex-direction: column;
|
|
@@ -86,7 +75,9 @@ body {
|
|
|
86
75
|
font-size: 14px;
|
|
87
76
|
}
|
|
88
77
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
78
|
+
/* Icon defaults to text-foreground, which overrides the parent Button text
|
|
79
|
+
colour (primary-foreground on filled buttons, …). Inherit so the glyph
|
|
80
|
+
matches its label — including hover states on outline/ghost buttons. */
|
|
81
|
+
button svg.text-foreground {
|
|
82
|
+
color: inherit;
|
|
92
83
|
}
|
|
@@ -26,7 +26,7 @@ function ErrorScreen({ message }: { message: string }) {
|
|
|
26
26
|
return (
|
|
27
27
|
<main className="screen screen--center">
|
|
28
28
|
<Alert variant="destructive" className="card">
|
|
29
|
-
<AlertTitle
|
|
29
|
+
<AlertTitle>เริ่มต้นแอปไม่สำเร็จ</AlertTitle>
|
|
30
30
|
<AlertDescription>{message}</AlertDescription>
|
|
31
31
|
</Alert>
|
|
32
32
|
</main>
|
|
@@ -30,6 +30,7 @@ export type Bootstrap =
|
|
|
30
30
|
| { kind: "error"; message: string };
|
|
31
31
|
|
|
32
32
|
let token: string | null = null;
|
|
33
|
+
let sdk: PPLEMiniApp | null = null;
|
|
33
34
|
|
|
34
35
|
/** Establish the session. Call once at startup, before rendering the app. */
|
|
35
36
|
export async function bootstrap(): Promise<Bootstrap> {
|
|
@@ -48,11 +49,11 @@ export async function bootstrap(): Promise<Bootstrap> {
|
|
|
48
49
|
return {
|
|
49
50
|
kind: "error",
|
|
50
51
|
message:
|
|
51
|
-
"
|
|
52
|
+
"แอปตั้งค่าไม่ถูกต้อง: การตั้งค่าเข้าสู่ระบบ PPLE ID ไม่ได้ถูกตั้งตอนสร้างแอป โปรดดีพลอยใหม่จากแพลตฟอร์ม",
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
sdk = new PPLEMiniApp({
|
|
56
57
|
oauthUrl: OAUTH_URL,
|
|
57
58
|
oauthClientId: OAUTH_CLIENT_ID,
|
|
58
59
|
oauthRedirectUri: OAUTH_REDIRECT_URI,
|
|
@@ -65,6 +66,16 @@ export async function bootstrap(): Promise<Bootstrap> {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/** Drop the session and return to the start (re-triggers PPLE ID login). */
|
|
70
|
+
export async function logout(): Promise<void> {
|
|
71
|
+
token = null;
|
|
72
|
+
if (sdk) {
|
|
73
|
+
await sdk.logout();
|
|
74
|
+
sdk = null;
|
|
75
|
+
}
|
|
76
|
+
window.location.href = "/";
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* Call your Worker API with the bearer token attached. Paths are relative
|
|
70
81
|
* ("/api/notes"), since the SPA and the Worker share one origin.
|