@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.
@@ -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
- Logo,
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 { Me, Note, UploadedFile } from "../shared/api";
17
- import { api, apiJson } from "./pple";
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: "Could not load your profile",
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
- <main className="screen">
42
- <header className="appbar">
43
- <Logo className="appbar__logo" title="People's Party" />
44
- <span className="appbar__title">PPLE App</span>
45
- {me && <Badge>{me.actor.name ?? me.actor.sub}</Badge>}
46
- </header>
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
- <div className="stack">
49
- <p className="muted">
50
- This is the golden-path template. Each card below exercises one
51
- platform capability. Replace them with your own app.
52
- </p>
53
- <NotesCard />
54
- <FilesCard />
55
- <NotifyCard />
56
- </div>
57
- </main>
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: "Could not load notes", description: message(e), variant: "error" });
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: "Could not save note", description: message(e), variant: "error" });
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>Notes</CardTitle>
102
- <CardDescription>Reads and writes your app's D1 database.</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="Write a note…"
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 /> : "Add"}
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">No notes yet.</p>
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">{new Date(n.createdAt).toLocaleString()}</span>
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: "Could not load files", description: message(e), variant: "error" });
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(`upload failed (${res.status})`);
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: "Could not upload file", description: message(e), variant: "error" });
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>Files</CardTitle>
176
- <CardDescription>Uploads to your app's R2 bucket.</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
- {/* Disabled while uploading, so picking a second file can't start
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="file-input"
263
+ className="hidden"
187
264
  onChange={upload}
188
265
  disabled={uploading}
189
266
  />
190
- {uploading && <Spinner />}
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">No files yet.</p>
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
- // --- Audience-bound notifications ----------------------------------------
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 (!title.trim() || !body.trim()) return;
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 { error?: string };
367
+ const data = (await res.json().catch(() => ({}))) as {
368
+ error?: string;
369
+ results?: NotificationDelivery[];
370
+ };
228
371
  if (res.ok) {
229
- showToast({ title: "Notification sent", variant: "success" });
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: "Notifications not configured",
235
- description: "Set NOTIFY_API_BASE and NOTIFY_API_KEY to send for real.",
378
+ title: "ยังไม่ได้ตั้งค่าการแจ้งเตือน",
379
+ description: "ตั้งค่า NOTIFY_API_BASE และ NOTIFY_API_KEY เพื่อส่งจริง",
236
380
  variant: "warning",
237
381
  });
238
382
  } else {
239
- showToast({ title: "Could not send", description: data.error, variant: "error" });
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: "Could not send", description: message(e), variant: "error" });
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>Notify your users</CardTitle>
399
+ <CardTitle>แจ้งเตือนผู้ใช้</CardTitle>
252
400
  <CardDescription>
253
- Sends an audience-bound push to your App Users — content only, no
254
- phone numbers.
401
+ ส่งถึงผู้ใช้ของแอปนี้ — ทุกคน หรือระบุรายบุคคลด้วย PPLE ID (sub)
402
+ หรือเบอร์โทรศัพท์ การระบุชื่อเป็นการ<em>จำกัด</em>ผู้รับให้แคบลงเท่านั้น
403
+ ไม่เคยขยายออก คนที่ยังไม่เคยเปิดแอปนี้จะไม่ได้รับ
255
404
  </CardDescription>
256
405
  </CardHeader>
257
406
  <CardContent>
258
407
  <div className="stack">
259
- <Input value={title} placeholder="Title" onChange={(e) => setTitle(e.target.value)} />
260
- <Textarea value={body} placeholder="Message" onChange={(e) => setBody(e.target.value)} />
261
- <Button onClick={send} disabled={sending || !title.trim() || !body.trim()}>
262
- {sending ? <Spinner /> : "Send notification"}
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
- .file-input {
90
- flex: 1;
91
- min-width: 0;
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>Could not start</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
- "This app is misconfigured: its PPLE ID sign-in settings were not set when it was built. Redeploy the app from the platform to fix it.",
52
+ "แอปตั้งค่าไม่ถูกต้อง: การตั้งค่าเข้าสู่ระบบ PPLE ID ไม่ได้ถูกตั้งตอนสร้างแอป โปรดดีพลอยใหม่จากแพลตฟอร์ม",
52
53
  };
53
54
  }
54
55
 
55
- const sdk = new PPLEMiniApp({
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.