@suphark/ui 0.45.2 → 0.53.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/CHANGELOG.md CHANGED
@@ -2,7 +2,85 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.45.2] — 2026-10-08
5
+ ## [0.53.0] — 2026-10-23
6
+
7
+ ### Changed
8
+ - showcase: `/components/sortable-list` ปรับให้ตรง mockup — หัวตาราง "ตัวแปรสภาพแวดล้อม · N คีย์" + ปุ่ม ? ด้านบน, ตัวเลขจำนวนคีย์ในหัวกลุ่ม, เส้นนำแนวตั้งของคีย์ในกลุ่ม, สีตอนชี้แถว, เส้นแทรกสีหลักที่แถวที่กำลังลาก, แคปซูลเลือกหมู่อยู่ใต้ตาราง
9
+ - `DatePicker` ปรับให้ตรง mockup "Type first" (0.49.0 วาดไม่ตรง): ไอคอนปฏิทินอยู่ซ้ายของช่อง · ป้าย `Enter` อยู่ในช่องขวา (เดิมอยู่ในกล่องผล) · กล่องผลที่อ่านออก (เขียว) แยกเป็นกล่องของตัวเองเหนือการ์ดปฏิทิน · หัวปฏิทินเป็นปุ่มเดียว "ตุลาคม 2569 ▾" กดแล้วเป็นตารางเลือกเดือน/ปี (แทนเมนูเดือน + เมนูปีสองอัน) · แผ่น "ทั้งหมด" มีหัว "พิมพ์ลัดทั้งหมด · กดเพื่อเติมในช่อง" · `UiText.datePicker.allShortcuts`/`tapToFill`
10
+
11
+ ## [0.52.0] — 2026-10-23
12
+
13
+ ### Changed
14
+ - `variant="quiet"` ของ `RecordCard`/`NotificationCenter` ปรับให้ตรงแบบ mockup จริง (0.51.0 วาดไม่ตรง): ไอคอนอยู่ในกรอบ 36px พื้น muted สีตาม accent · `StatusBadge` ที่อยู่ในแถว quiet กลายเป็น "จุดสี + ข้อความสี" ไม่มีกรอบ/พื้นอัตโนมัติ (CSS `in-data-[variant=quiet]`, นอก quiet เหมือนเดิม) · ปุ่มรองเป็น outline (เดิม ghost) ปุ่มหลักใช้แถวเดียว
15
+
16
+ ### Added
17
+ - `RecordCard`/`NotificationEntry` `highlight` (quiet: พื้น accent จาง ๆ เน้นแถวที่ต้องจัดการก่อน) และ `progress` (quiet: แถบความคืบหน้า 0–100 ใต้ยอด)
18
+ - showcase: notification-center โหมด quiet ตามแบบ — ยอดไม่มีสตางค์, แถวแรกเน้น + ปุ่มหลักตัวเดียว, แถบ 35/60 รอบ, เมนู … ทุกแถว
19
+
20
+ ## [0.51.0] — 2026-10-23
21
+
22
+ ### Added
23
+ - `useSortableRow().toggleSelected()` — เลือก/ไม่เลือกแถวจากช่องติ๊กในแถว (ช่องติ๊กโผล่เมื่อชี้ เหมือนที่จับ)
24
+ - `SelectionBar variant="pill"` — แคปซูลสีหลักลอยกลางล่างจอ เด่นกว่า `floating` (ใช้กับแถบเลือกหมู่ของ Sortable)
25
+ - `RecordCard variant="quiet"` (opt-in, ค่าเริ่มต้น `card` เหมือนเดิม) — แถวเรียบแบบสมุดบัญชี "Quiet ledger": ไม่มีกรอบ/พื้นสี สีของ `accent` เหลือแค่ไอคอน, ชื่อ + (สถานะ · meta) ซ้าย, ยอดเงินขวา, ปุ่มท้ายแถว — เห็นได้มากกว่าเท่าตัวในที่เท่ากัน · props เดิมทั้งหมดใช้ต่อได้ ไม่ใช่ major
26
+ - `NotificationCenter variant="quiet"` + `NotificationEntry.section` — ชิปนับเป็นแท็บขีดเส้นใต้ (ตัวเลข danger เป็นสีแดง) รายการเป็นแถว quiet ในกรอบเดียว มีหัวกลุ่มคั่นตาม `section`
27
+ - showcase: sortable-list มีช่องติ๊กโผล่เมื่อชี้ + แคปซูลเลือกหมู่ + กรอบบอกตำแหน่งเดิมตอนลาก; record-card / notification-center มีตัวอย่าง quiet (notification-center สลับ card/quiet ได้)
28
+
29
+ ## [0.50.0] — 2026-10-23
30
+
31
+ ### Added
32
+ - `@suphark/ui/sortable`: `DragHandle reveal` (opt-in, ค่าเริ่มต้นเหมือนเดิม) — "สงบจนกว่าจะแตะ": ซ่อนที่จับ โผล่เมื่อชี้แถว / โฟกัสในแถว / แถวถูกเลือก (จอสัมผัสเห็นตลอด, Tab ถึงที่จับได้เสมอ) · แอปใส่ `group/row` ที่แถว
33
+ - showcase: `/components/sortable-list` ใช้ `reveal` และย้ายข้อความสอนยาวไว้หลังปุ่ม ? (แผ่นลัดแป้นพิมพ์)
34
+
35
+ ## [0.49.0] — 2026-10-23
36
+
37
+ ### Changed
38
+
39
+ - **`DatePicker` redesign "Type first"** (ทั้งวันเดียวและ `mode="range"`; API/props/ค่าที่ส่งในฟอร์ม `name`/`defaultValue`/`required` เหมือนเดิมทุกตัว — แอปที่ใช้อยู่ไม่ต้องแก้):
40
+ - **ช่องคือ input ที่พิมพ์ได้โดยตรง** (เดิมเป็นปุ่มที่ต้องเปิดป๊อปอัปก่อนถึงมีช่องพิมพ์ซ้อนอยู่ข้างใน) — คลิกช่อง = เลือกทั้งข้อความ พิมพ์ทับได้เลย · `<label>` ที่ครอบ/ชี้ (`LabeledField`) โฟกัสที่ช่องพิมพ์โดยตรง
41
+ - **เห็นผลที่อ่านออกใต้ช่องทันที**: "จ. 26 ต.ค. 2569 · ในอีก 3 วัน" / ช่วง "16–23 ต.ค. 2569 · 8 วัน" (กดผลนั้นหรือ Enter เพื่อยืนยัน) — แก้ปัญหา "10/02 อ่านได้สองแบบ"; อ่านไม่ออก = "อ่านไม่ออก — ลองพิมพ์ t+3" ไม่ทิ้งค่าที่พิมพ์ทิ้งเงียบ ๆ; พิมพ์วันเดียวในช่องช่วง = ช่วงหนึ่งวัน
42
+ - ปฏิทินเลื่อนตามข้อความที่พิมพ์และไฮไลต์วันที่อ่านได้ทันที · แถวพิมพ์ลัดบรรทัดเดียวใต้ปฏิทิน (กดเติมลงช่อง) + "ทั้งหมด ›" เปิดแผ่นตัวอย่างวิธีพิมพ์ในกล่องเดียวกัน — **เลิกปุ่ม (i) / ตารางวิธีพิมพ์ที่กินที่ / ปุ่ม "ตกลง"**
43
+ - ปุ่ม ✕ ล้างค่าและปุ่มเปิดปฏิทินเป็น `<button>` จริง (เดิม ✕ เป็น `<span role="button">`) · วันที่สัมพัทธ์ "(พรุ่งนี้)" ยังแสดงท้ายช่องตาม `showRelative`
44
+ - **ปฏิทินใช้ภาษา/ปีเดียวกับช่อง** (เดิมไม่ส่ง `locale` แล้วขึ้น "Oct 2026" อังกฤษ ทั้งที่ช่องแสดง พ.ศ.) · หัวคอลัมน์วันไทย 2 ตัว (อา จ อ พ พฤ ศ ส) แทนตัวเดียวที่ "อ"/"พ" กำกวม
45
+ - คลิกวันที่ที่เลือกอยู่แล้วในปฏิทิน = ยืนยันวันนั้น (เดิมล้างค่าแบบ toggle — ล้างใช้ปุ่ม ✕)
46
+ - `showSmartInput={false}` = ช่องอ่านอย่างเดียว กดแล้วเปิดปฏิทินอย่างเดียว (แบบเดิม ไม่มีแถบพิมพ์ลัด) · `showPresets` ยัง deprecated ไม่มีผล
47
+ - ป๊อปอัปไม่มี animation ตอนปิด (Base UI ถอดตัวดักโฟกัส `<span tabindex=0>` ที่วางต่อท้ายช่องก็ต่อเมื่อ animation จบ ระหว่างนั้น Tab ตกไปที่มัน)
48
+ - `UiText.datePicker` เพิ่ม `cannotRead` (`{example}`), `shortcutsLabel`, `showAll`, `backToCalendar`, `openCalendar`, `dayCount` (`{count}`) — ไทย/อังกฤษในตัว
49
+
50
+ ### Fixed
51
+
52
+ - **คีย์บอร์ด: ลูกศรเลือกวันในปฏิทินไม่ย้ายโฟกัสจริง** — `CalendarDayButton` ใน `components/ui/calendar.tsx` (ไฟล์ shadcn) มี effect สั่ง `ref.current?.focus()` แต่ **ไม่เคยผูก `ref` กับปุ่ม** ลูกศรจึงเลื่อนแค่สถานะภายในของ react-day-picker โฟกัสจริงค้างที่เดิม (กระทบ `Calendar` ทุกที่ รวม DatePicker แบบเดิม) · `DatePicker` แก้ที่ฝั่งตัวเองด้วย `components={{ DayButton }}` ที่ผูก ref ให้ · **`Calendar` ดิบที่แอปใช้เองยังมีบั๊กนี้** (ห้ามแก้ไฟล์ shadcn — จดใน todo)
53
+ - ป๊อปอัป DatePicker สูง ~400px ไม่พอทั้งบนและล่างแล้ว Base UI ย้ายไปวางข้างช่อง (ทับเนื้อหา) → พลิกได้เฉพาะบน↔ล่าง
54
+ - Tab ออกจากช่องที่เปิดป๊อปอัปอยู่ แล้วโฟกัสหลุดไป `body` แทนที่จะไปช่องถัดไป (ตัวดักโฟกัสของ Base UI)
55
+
56
+ ### Security
57
+
58
+ - อัป `next` (devDependency ของแพ็กเกจ + showcase ui.suphark.com) เป็น `^16.4.2` — ปิดช่องโหว่ใน `next >=16.0.0 <=16.4.0`: **critical** RCE ไม่ต้องล็อกอินใน Image Optimization API เมื่อใช้ไฟล์ SVG (GHSA-2464-fhr4-w9xw) · **critical** RCE ไม่ต้องล็อกอินใน `use cache` ที่คืน typed array (GHSA-673q-mjm6-r5xr) · high DoS ใน App Router ที่ใช้ Server Actions · override `source-map-js >=1.2.2` (high DoS ที่ติดมากับ postcss) · `pnpm audit --prod --audit-level=high` สะอาด · **peerDependency `next >=15` ไม่เปลี่ยน** — แอปที่ใช้ Next 16 อัปเองเป็น `^16.4.2` (แพ็กเกจส่ง source ไม่ได้บังคับเวอร์ชัน next)
59
+
60
+ ### Added
61
+
62
+ - `HeaderBreadcrumbs` (และ `AppShell` `header.breadcrumbs: { upButton }`): **`upButton`** + `upLabel` — ปุ่ม X ท้าย breadcrumb ถอยขึ้นหนึ่งระดับแบบ Vercel (ไปที่ segment ก่อนหน้า หรือ `homeHref` ถ้ามี segment เดียว · ไม่แสดงหน้าแรก) · ปิดไว้เป็นค่าเริ่มต้น แอปเดิมไม่เปลี่ยน (ขอจาก Pi Config ซึ่งทำเองใน `breadcrumbs.tsx` แล้ว) · showcase `/shell` เปิดใช้
63
+
64
+ ## [0.48.0] — 2026-10-10
65
+
66
+ > ประกาศพร้อม publish แล้วแต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.49.0
67
+
68
+ ### Added
69
+
70
+ - showcase: หน้า `/components/web-push-row` (demo WebPushRow)
71
+ - WebPushRow (@suphark/ui/pwa): แถวตั้งค่าเปิด/ปิดการแจ้งเตือน Web Push บนเบราว์เซอร์ พร้อมจัดการ Service Worker registration, PushManager subscription, permission check, และ Feedback toast อัตโนมัติ
72
+
73
+ ## [0.47.0] — 2026-10-08
74
+
75
+ ### Changed
76
+
77
+ - **`@suphark/ui/sortable` รองรับโครงเดียว: หัวกลุ่มกับแถวรายการเป็นพี่น้องกัน** (`<tr>` ใน `<tbody>` เดียวกัน หรือการ์ดในลิสต์เดียวกัน) — เจ้าของเลือกให้เหลือรูปแบบเดียวเพื่อดูแลง่าย หลัง Pi Config ย้ายจากโครง "`<tbody>` ต่อกลุ่ม" มาแบบนี้แล้ว (71735f0) · **ห้ามให้ node ของกลุ่มครอบแถวรายการไว้** (เช่นแปะ `ref` ของกลุ่มที่ `<tbody>` ที่ห่อแถวคีย์) ไม่งั้นแถวลูกจะเลื่อนสองชั้นและกรอบของก้อนถูกนับซ้ำ · ถอดโค้ดที่เคยรองรับโครงนั้นออก (`insideGroup` ใน context ภายใน, การกันนับซ้ำใน `blockRect`) — ไม่มี props ใดเปลี่ยน แอปที่ทำตามตัวอย่างใน `/components/sortable-list` ไม่ต้องแก้อะไร
78
+
79
+
80
+ ### Added
81
+
82
+ - type `DistributiveOmit<T, K>` / `DistributivePick<T, K>` (ขอจาก Pi Auth): `Omit` ปกติยุบ union ของ props ที่มี discriminant — `Omit<DatePickerProps, "locale">` ทำให้ `name` กลายเป็น `string | [string, string]` แยกตาม `mode` ไม่ได้อีก ตัวห่อของแอปจึงพัง · ใช้ `DistributiveOmit<DatePickerProps, "locale">` แทนได้เลย ไม่ต้อง cast (มีเทสต์ type ที่พิสูจน์แล้วว่าแดงเมื่อถอดตัวแก้)
83
+
6
84
 
7
85
  ### Fixed
8
86
 
package/README.md CHANGED
@@ -236,6 +236,8 @@ const [order, setOrder] = useState(() =>
236
236
  - การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
237
237
  - เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
238
238
  - จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้): `SortableProvider freeLayout` + เรนเดอร์ตาม `rootEntries(order)` (ครอบทั้งหมดด้วย `<SortableGroup itemIds={order.items[UNGROUPED]}>` แล้วแต่ละกลุ่มมี `SortableGroup` ของตัวเองข้างใน) + บันทึก `payload.root` และส่งกลับ `sortableOrderOf(..., { root })` ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม
239
+ - `DatePicker`: ช่องพิมพ์ได้โดยตรง — "15/3", "15/3/69", "t+3", "t+1m", ช่วง "1/7-5/7" / "t-7..t" (`mode="range"`) เห็นผลที่อ่านออกใต้ช่อง กด Enter ยืนยัน · ↓ เปิดปฏิทิน/เข้าปฏิทิน (ลูกศร+Enter เลือกวัน) · Esc ยกเลิก · ในฟอร์ม `name`/`defaultValue`/`required` ส่ง YYYY-MM-DD · ห่อใน `LabeledField` ได้ (label โฟกัสที่ช่องพิมพ์)
240
+ - ห่อ component ที่ props เป็น union (เช่น `DatePicker` ที่มี `mode="range"`): ใช้ `DistributiveOmit`/`DistributivePick` แทน `Omit`/`Pick` ไม่งั้น union ถูกยุบจนแยกตาม `mode` ไม่ได้
239
241
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
240
242
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
241
243
  - ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.45.2",
3
+ "version": "0.53.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -120,7 +120,7 @@
120
120
  "@vitejs/plugin-react": "^6.1.0",
121
121
  "jsdom": "^29.1.1",
122
122
  "lucide-react": "^1.45.0",
123
- "next": "^16.3.8",
123
+ "next": "^16.4.2",
124
124
  "react": "^19.3.0",
125
125
  "react-dom": "^19.3.0",
126
126
  "react-hook-form": "^7.86.0",
@@ -8,6 +8,7 @@ import {
8
8
  BreadcrumbPage,
9
9
  BreadcrumbSeparator,
10
10
  } from "@suphark/ui/components/ui/breadcrumb";
11
+ import { XIcon } from "lucide-react";
11
12
  import Link from "next/link";
12
13
  import { usePathname } from "next/navigation";
13
14
  import { Fragment, type ReactNode } from "react";
@@ -28,6 +29,13 @@ export interface HeaderBreadcrumbsProps {
28
29
  skip?: string[];
29
30
  /** ซ่อน segment ที่ดูเหมือน id (ตัวเลข/uuid/cuid) */
30
31
  hideIds?: boolean;
32
+ /**
33
+ * ปุ่ม X ท้าย breadcrumb ถอยขึ้นหนึ่งระดับ (แบบ Vercel) — ไปที่ segment ก่อนหน้า หรือ `homeHref` ถ้ามี segment เดียว
34
+ * ไม่แสดงในหน้าแรก (ไม่มี segment) · ปิดไว้เป็นค่าเริ่มต้น จึงไม่เปลี่ยนแอปเดิม
35
+ */
36
+ upButton?: boolean;
37
+ /** ข้อความของปุ่ม X (aria-label + tooltip) — ค่าเริ่มต้น "Back" */
38
+ upLabel?: string;
31
39
  }
32
40
 
33
41
  // ตัวเลขล้วน / uuid / cuid / ตัวอักษร+ตัวเลขปน ≥16 ตัว (ต้องมีตัวเลขอย่างน้อย 1 — คำธรรมดายาว ๆ ไม่ใช่ id)
@@ -47,6 +55,8 @@ export function HeaderBreadcrumbs({
47
55
  homeLabel = "Home",
48
56
  skip = [],
49
57
  hideIds = false,
58
+ upButton = false,
59
+ upLabel = "Back",
50
60
  }: HeaderBreadcrumbsProps) {
51
61
  const pathname = usePathname();
52
62
  const all = pathname.split("/").filter(Boolean);
@@ -55,6 +65,8 @@ export function HeaderBreadcrumbs({
55
65
  .map((segment, index) => ({ segment, href: `/${all.slice(0, index + 1).join("/")}` }))
56
66
  .filter(({ segment }) => !skip.includes(segment) && !(hideIds && ID_LIKE.test(segment)));
57
67
 
68
+ const upHref = shown.length >= 2 ? (shown[shown.length - 2]?.href ?? homeHref) : homeHref;
69
+
58
70
  return (
59
71
  <Breadcrumb>
60
72
  <BreadcrumbList>
@@ -80,6 +92,18 @@ export function HeaderBreadcrumbs({
80
92
  </Fragment>
81
93
  );
82
94
  })}
95
+ {upButton && shown.length > 0 && (
96
+ <BreadcrumbItem>
97
+ <Link
98
+ href={upHref}
99
+ aria-label={upLabel}
100
+ title={upLabel}
101
+ className="inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
102
+ >
103
+ <XIcon className="size-3.5" aria-hidden="true" />
104
+ </Link>
105
+ </BreadcrumbItem>
106
+ )}
83
107
  </BreadcrumbList>
84
108
  </Breadcrumb>
85
109
  );
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { SwitchRow } from "@suphark/ui/components/shared/settings-row";
4
+ import { type ReactNode, useEffect, useState, useTransition } from "react";
5
+ import { toast } from "sonner";
6
+
7
+ export interface WebPushRowProps {
8
+ /** ฟังก์ชันดึง VAPID Public Key จาก server action */
9
+ getPublicKey: () => Promise<string>;
10
+ /** Callback เมื่อผู้ใช้เปิดรับการแจ้งเตือน (ส่ง subscription ให้ server บันทึก) */
11
+ onSubscribe: (subscription: PushSubscriptionJSON) => Promise<unknown>;
12
+ /** Callback เมื่อผู้ใช้ปิดการแจ้งเตือน (ส่ง endpoint ให้ server ลบ) */
13
+ onUnsubscribe?: (endpoint: string) => Promise<unknown>;
14
+ /** Path ของ Service Worker (default: "/sw.js") */
15
+ swPath?: string;
16
+ /** หัวข้อแถว (default: "การแจ้งเตือนบนเบราว์เซอร์") */
17
+ title?: ReactNode;
18
+ /** คำอธิบาย (default: "รับแจ้งเตือนเตือนความจำและอัปเดตงานทันทีผ่าน Web Push") */
19
+ description?: ReactNode;
20
+ /** ข้อความเมื่อเปิดสำเร็จ (default: "เปิดรับการแจ้งเตือนบนเบราว์เซอร์แล้ว") */
21
+ successSubscribeMessage?: string;
22
+ /** ข้อความเมื่อปิดสำเร็จ (default: "ปิดการแจ้งเตือนแล้ว") */
23
+ successUnsubscribeMessage?: string;
24
+ /** ปิดการทำงาน (disabled) เพิ่มเติม */
25
+ disabled?: boolean;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * แถวตั้งค่าเปิด/ปิดการแจ้งเตือน Web Push บนเบราว์เซอร์
31
+ * จัดการ Service Worker registration, PushManager subscription, และ Feedback toast อัตโนมัติ
32
+ *
33
+ * <WebPushRow
34
+ * getPublicKey={getVapidKey}
35
+ * onSubscribe={registerPush}
36
+ * onUnsubscribe={unregisterPush}
37
+ * />
38
+ */
39
+ export function WebPushRow({
40
+ getPublicKey,
41
+ onSubscribe,
42
+ onUnsubscribe,
43
+ swPath = "/sw.js",
44
+ title = "การแจ้งเตือนบนเบราว์เซอร์",
45
+ description = "รับแจ้งเตือนเตือนความจำและอัปเดตงานทันทีผ่าน Web Push",
46
+ successSubscribeMessage = "เปิดรับการแจ้งเตือนบนเบราว์เซอร์แล้ว",
47
+ successUnsubscribeMessage = "ปิดการแจ้งเตือนแล้ว",
48
+ disabled: customDisabled,
49
+ className,
50
+ }: WebPushRowProps) {
51
+ const [enabled, setEnabled] = useState(false);
52
+ const [supported, setSupported] = useState(true);
53
+ const [pending, startTransition] = useTransition();
54
+
55
+ useEffect(() => {
56
+ if (typeof window === "undefined") return;
57
+ if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
58
+ setSupported(false);
59
+ return;
60
+ }
61
+ navigator.serviceWorker.ready
62
+ .then((reg) => reg.pushManager.getSubscription())
63
+ .then((sub) => {
64
+ setEnabled(Boolean(sub));
65
+ })
66
+ .catch(() => {
67
+ setEnabled(false);
68
+ });
69
+ }, []);
70
+
71
+ const handleToggle = (checked: boolean) => {
72
+ if (!supported) {
73
+ toast.error("เบราว์เซอร์นี้ไม่รองรับ Web Push");
74
+ return;
75
+ }
76
+
77
+ startTransition(async () => {
78
+ try {
79
+ const reg = await navigator.serviceWorker.register(swPath);
80
+ await navigator.serviceWorker.ready;
81
+
82
+ if (checked) {
83
+ if (Notification.permission === "denied") {
84
+ toast.error("คุณบล็อกการแจ้งเตือนไว้ในเบราว์เซอร์ โปรดปลดบล็อกในการตั้งค่าไซต์");
85
+ return;
86
+ }
87
+
88
+ const key = await getPublicKey();
89
+ const sub = await reg.pushManager.subscribe({
90
+ userVisibleOnly: true,
91
+ applicationServerKey: key,
92
+ });
93
+
94
+ await onSubscribe(sub.toJSON());
95
+ setEnabled(true);
96
+ toast.success(successSubscribeMessage);
97
+ } else {
98
+ const sub = await reg.pushManager.getSubscription();
99
+ if (sub) {
100
+ const endpoint = sub.endpoint;
101
+ await sub.unsubscribe();
102
+ if (onUnsubscribe) {
103
+ await onUnsubscribe(endpoint);
104
+ }
105
+ }
106
+ setEnabled(false);
107
+ toast.success(successUnsubscribeMessage);
108
+ }
109
+ } catch (err) {
110
+ console.error("WebPushRow error:", err);
111
+ toast.error("ตั้งค่าไม่สำเร็จ (ตรวจดูการอนุญาตแจ้งเตือนในเบราว์เซอร์)");
112
+ }
113
+ });
114
+ };
115
+
116
+ return (
117
+ <SwitchRow
118
+ title={title}
119
+ description={supported ? description : "เบราว์เซอร์นี้ไม่รองรับการแจ้งเตือน Web Push"}
120
+ checked={enabled}
121
+ disabled={!supported || pending || customDisabled}
122
+ onCheckedChange={handleToggle}
123
+ className={className}
124
+ />
125
+ );
126
+ }