@eifi1/ui-kit 0.35.0 → 0.37.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.
Files changed (269) hide show
  1. package/README.md +35 -5
  2. package/dist/admin/role-select.d.ts +3 -2
  3. package/dist/admin/role-select.js +2 -0
  4. package/dist/admin/role-select.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -2
  6. package/dist/components/amount-input.js.map +1 -1
  7. package/dist/components/autocomplete.d.ts +2 -2
  8. package/dist/components/autocomplete.js +2 -1
  9. package/dist/components/autocomplete.js.map +1 -1
  10. package/dist/components/button-look.d.ts +1 -1
  11. package/dist/components/button-look.js +9 -4
  12. package/dist/components/button-look.js.map +1 -1
  13. package/dist/components/checkbox-group.d.ts +5 -2
  14. package/dist/components/checkbox-group.js +15 -4
  15. package/dist/components/checkbox-group.js.map +1 -1
  16. package/dist/components/column-mapper.d.ts +5 -3
  17. package/dist/components/column-mapper.js +2 -0
  18. package/dist/components/column-mapper.js.map +1 -1
  19. package/dist/components/combobox.d.ts +2 -2
  20. package/dist/components/combobox.js +2 -1
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/country-select.d.ts +3 -3
  23. package/dist/components/country-select.js +3 -0
  24. package/dist/components/country-select.js.map +1 -1
  25. package/dist/components/currency-select.d.ts +3 -2
  26. package/dist/components/currency-select.js +10 -1
  27. package/dist/components/currency-select.js.map +1 -1
  28. package/dist/components/date-field.d.ts +2 -1
  29. package/dist/components/date-field.js.map +1 -1
  30. package/dist/components/date-picker.js +3 -0
  31. package/dist/components/date-picker.js.map +1 -1
  32. package/dist/components/date-range-picker.js +3 -0
  33. package/dist/components/date-range-picker.js.map +1 -1
  34. package/dist/components/entity-combobox.d.ts +2 -2
  35. package/dist/components/entity-combobox.js +2 -1
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/field-parts.d.ts +66 -11
  38. package/dist/components/field-parts.js +32 -10
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +3 -2
  41. package/dist/components/field-strip.js +2 -1
  42. package/dist/components/field-strip.js.map +1 -1
  43. package/dist/components/field.d.ts +3 -2
  44. package/dist/components/field.js +24 -15
  45. package/dist/components/field.js.map +1 -1
  46. package/dist/components/help-popover.d.ts +9 -7
  47. package/dist/components/help-popover.js.map +1 -1
  48. package/dist/components/icon-picker.d.ts +3 -2
  49. package/dist/components/icon-picker.js +3 -0
  50. package/dist/components/icon-picker.js.map +1 -1
  51. package/dist/components/inline-entity-combobox.d.ts +2 -2
  52. package/dist/components/inline-entity-combobox.js +2 -1
  53. package/dist/components/inline-entity-combobox.js.map +1 -1
  54. package/dist/components/input.d.ts +2 -2
  55. package/dist/components/input.js +2 -1
  56. package/dist/components/input.js.map +1 -1
  57. package/dist/components/month-picker.d.ts +2 -1
  58. package/dist/components/month-picker.js +8 -1
  59. package/dist/components/month-picker.js.map +1 -1
  60. package/dist/components/multi-entity-combobox.d.ts +2 -2
  61. package/dist/components/multi-entity-combobox.js +2 -1
  62. package/dist/components/multi-entity-combobox.js.map +1 -1
  63. package/dist/components/multi-select.d.ts +2 -2
  64. package/dist/components/multi-select.js +3 -0
  65. package/dist/components/multi-select.js.map +1 -1
  66. package/dist/components/number-input.d.ts +2 -2
  67. package/dist/components/number-input.js.map +1 -1
  68. package/dist/components/numeric-field.d.ts +5 -2
  69. package/dist/components/numeric-field.js +4 -1
  70. package/dist/components/numeric-field.js.map +1 -1
  71. package/dist/components/one-time-code-input.d.ts +2 -1
  72. package/dist/components/one-time-code-input.js +2 -1
  73. package/dist/components/one-time-code-input.js.map +1 -1
  74. package/dist/components/phone-input.d.ts +1 -1
  75. package/dist/components/phone-input.js +4 -0
  76. package/dist/components/phone-input.js.map +1 -1
  77. package/dist/components/popover.js +1 -1
  78. package/dist/components/popover.js.map +1 -1
  79. package/dist/components/select.d.ts +4 -3
  80. package/dist/components/select.js +2 -1
  81. package/dist/components/select.js.map +1 -1
  82. package/dist/components/slider.d.ts +3 -2
  83. package/dist/components/slider.js +7 -1
  84. package/dist/components/slider.js.map +1 -1
  85. package/dist/components/stat-tile.js +2 -11
  86. package/dist/components/stat-tile.js.map +1 -1
  87. package/dist/components/swatch-picker.d.ts +3 -2
  88. package/dist/components/swatch-picker.js +3 -0
  89. package/dist/components/swatch-picker.js.map +1 -1
  90. package/dist/components/text-link.js +3 -1
  91. package/dist/components/text-link.js.map +1 -1
  92. package/dist/components/textarea.d.ts +2 -1
  93. package/dist/components/textarea.js +9 -3
  94. package/dist/components/textarea.js.map +1 -1
  95. package/dist/components/toggle-group.d.ts +2 -2
  96. package/dist/components/toggle-group.js +7 -3
  97. package/dist/components/toggle-group.js.map +1 -1
  98. package/dist/eslint/index.js +1 -1
  99. package/dist/eslint/role-utilities.d.ts +34 -2
  100. package/dist/eslint/role-utilities.js +30 -9
  101. package/dist/eslint/role-utilities.js.map +1 -1
  102. package/dist/i18n/defaults.js +5 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/german.js +97 -1
  105. package/dist/i18n/german.js.map +1 -1
  106. package/dist/i18n/kit-labels.d.ts +16 -0
  107. package/dist/i18n/kit-labels.js +21 -1
  108. package/dist/i18n/kit-labels.js.map +1 -1
  109. package/dist/i18n/locales/en.js +103 -2
  110. package/dist/i18n/locales/en.js.map +3 -3
  111. package/dist/i18n/locales/es.js +95 -1
  112. package/dist/i18n/locales/es.js.map +1 -1
  113. package/dist/i18n/locales/fr.js +96 -1
  114. package/dist/i18n/locales/fr.js.map +1 -1
  115. package/dist/i18n/locales/hu.js +95 -1
  116. package/dist/i18n/locales/hu.js.map +1 -1
  117. package/dist/i18n/locales/it.js +96 -1
  118. package/dist/i18n/locales/it.js.map +1 -1
  119. package/dist/i18n/locales/zh.js +95 -1
  120. package/dist/i18n/locales/zh.js.map +1 -1
  121. package/dist/i18n/review.js +13 -0
  122. package/dist/i18n/review.js.map +1 -1
  123. package/dist/i18n/shared-labels.js +3 -1
  124. package/dist/i18n/shared-labels.js.map +1 -1
  125. package/dist/index.d.ts +21 -0
  126. package/dist/index.js +38 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/lib/device-settings.d.ts +72 -0
  129. package/dist/lib/device-settings.js +64 -0
  130. package/dist/lib/device-settings.js.map +1 -0
  131. package/dist/lib/plain-text.d.ts +11 -0
  132. package/dist/lib/plain-text.js +16 -0
  133. package/dist/lib/plain-text.js.map +1 -0
  134. package/dist/lib/safe-storage.d.ts +6 -0
  135. package/dist/lib/safe-storage.js +11 -0
  136. package/dist/lib/safe-storage.js.map +1 -1
  137. package/dist/notifications/notification-parts.d.ts +39 -0
  138. package/dist/notifications/notification-parts.js +47 -0
  139. package/dist/notifications/notification-parts.js.map +1 -0
  140. package/dist/notifications/notification-types-setting.d.ts +62 -0
  141. package/dist/notifications/notification-types-setting.js +192 -0
  142. package/dist/notifications/notification-types-setting.js.map +1 -0
  143. package/dist/notifications/notification-types.d.ts +75 -0
  144. package/dist/notifications/notification-types.js +2 -0
  145. package/dist/notifications/notification-types.js.map +1 -0
  146. package/dist/notifications/notifications-labels.d.ts +131 -0
  147. package/dist/notifications/notifications-labels.js +83 -0
  148. package/dist/notifications/notifications-labels.js.map +1 -0
  149. package/dist/notifications/push-device-setting.d.ts +37 -0
  150. package/dist/notifications/push-device-setting.js +164 -0
  151. package/dist/notifications/push-device-setting.js.map +1 -0
  152. package/dist/notifications/push-devices-setting.d.ts +44 -0
  153. package/dist/notifications/push-devices-setting.js +203 -0
  154. package/dist/notifications/push-devices-setting.js.map +1 -0
  155. package/dist/notifications/quiet-hours-setting.d.ts +47 -0
  156. package/dist/notifications/quiet-hours-setting.js +131 -0
  157. package/dist/notifications/quiet-hours-setting.js.map +1 -0
  158. package/dist/notifications/time-zone-select.d.ts +50 -0
  159. package/dist/notifications/time-zone-select.js +76 -0
  160. package/dist/notifications/time-zone-select.js.map +1 -0
  161. package/dist/notifications/unsubscribe-panel.d.ts +53 -0
  162. package/dist/notifications/unsubscribe-panel.js +139 -0
  163. package/dist/notifications/unsubscribe-panel.js.map +1 -0
  164. package/dist/notifications/use-push-device.d.ts +122 -0
  165. package/dist/notifications/use-push-device.js +230 -0
  166. package/dist/notifications/use-push-device.js.map +1 -0
  167. package/dist/rhf/fields.d.ts +26 -19
  168. package/dist/rhf/fields.js +51 -22
  169. package/dist/rhf/fields.js.map +1 -1
  170. package/dist/settings/device-reset-setting-labels.d.ts +3 -0
  171. package/dist/settings/device-reset-setting-labels.js +15 -0
  172. package/dist/settings/device-reset-setting-labels.js.map +1 -0
  173. package/dist/settings/device-reset-setting.d.ts +58 -0
  174. package/dist/settings/device-reset-setting.js +51 -0
  175. package/dist/settings/device-reset-setting.js.map +1 -0
  176. package/dist/shell/app-shell.js +5 -0
  177. package/dist/shell/app-shell.js.map +1 -1
  178. package/dist/theme/contrast-tokens.d.ts +42 -4
  179. package/dist/theme/contrast-tokens.js +56 -9
  180. package/dist/theme/contrast-tokens.js.map +1 -1
  181. package/dist/theme/contrast.d.ts +4 -2
  182. package/dist/theme/contrast.js +13 -0
  183. package/dist/theme/contrast.js.map +1 -1
  184. package/dist/theme/palette-derive.d.ts +15 -1
  185. package/dist/theme/palette-derive.js +37 -8
  186. package/dist/theme/palette-derive.js.map +1 -1
  187. package/dist/theme/palette-presets.d.ts +2 -1
  188. package/dist/theme/palette-presets.js +5 -5
  189. package/dist/theme/palette-presets.js.map +1 -1
  190. package/dist/theme/palette-store.js +6 -0
  191. package/dist/theme/palette-store.js.map +1 -1
  192. package/dist/theme/text-size.js +6 -0
  193. package/dist/theme/text-size.js.map +1 -1
  194. package/dist/theme/theme-store.js +6 -0
  195. package/dist/theme/theme-store.js.map +1 -1
  196. package/package.json +1 -1
  197. package/src/admin/role-select.tsx +4 -1
  198. package/src/components/amount-input.tsx +2 -1
  199. package/src/components/autocomplete.tsx +5 -2
  200. package/src/components/button-look.ts +15 -5
  201. package/src/components/checkbox-group.tsx +22 -4
  202. package/src/components/column-mapper.tsx +6 -2
  203. package/src/components/combobox.tsx +5 -2
  204. package/src/components/country-select.tsx +6 -1
  205. package/src/components/currency-select.tsx +12 -3
  206. package/src/components/date-field.tsx +2 -2
  207. package/src/components/date-picker.tsx +3 -0
  208. package/src/components/date-range-picker.tsx +3 -0
  209. package/src/components/entity-combobox.tsx +5 -2
  210. package/src/components/field-parts.tsx +107 -19
  211. package/src/components/field-strip.tsx +4 -3
  212. package/src/components/field.tsx +28 -15
  213. package/src/components/help-popover.tsx +9 -7
  214. package/src/components/icon-picker.tsx +8 -3
  215. package/src/components/inline-entity-combobox.tsx +5 -2
  216. package/src/components/input.tsx +4 -2
  217. package/src/components/month-picker.tsx +10 -3
  218. package/src/components/multi-entity-combobox.tsx +5 -2
  219. package/src/components/multi-select.tsx +6 -1
  220. package/src/components/number-input.tsx +2 -2
  221. package/src/components/numeric-field.ts +10 -2
  222. package/src/components/one-time-code-input.tsx +5 -3
  223. package/src/components/phone-input.tsx +4 -0
  224. package/src/components/popover.tsx +3 -1
  225. package/src/components/select.tsx +6 -3
  226. package/src/components/slider.tsx +10 -3
  227. package/src/components/stat-tile.tsx +3 -18
  228. package/src/components/swatch-picker.tsx +9 -4
  229. package/src/components/text-link.tsx +3 -1
  230. package/src/components/textarea.tsx +11 -4
  231. package/src/components/toggle-group.tsx +21 -5
  232. package/src/eslint/role-utilities.ts +67 -11
  233. package/src/i18n/defaults.ts +4 -0
  234. package/src/i18n/german.ts +113 -0
  235. package/src/i18n/kit-labels.tsx +52 -1
  236. package/src/i18n/locales/en.ts +6 -0
  237. package/src/i18n/locales/es.ts +106 -0
  238. package/src/i18n/locales/fr.ts +106 -0
  239. package/src/i18n/locales/hu.ts +104 -0
  240. package/src/i18n/locales/it.ts +105 -0
  241. package/src/i18n/locales/zh.ts +96 -0
  242. package/src/i18n/review.ts +13 -0
  243. package/src/i18n/shared-labels.ts +2 -0
  244. package/src/index.ts +50 -0
  245. package/src/lib/device-settings.ts +146 -0
  246. package/src/lib/plain-text.ts +22 -0
  247. package/src/lib/safe-storage.ts +16 -0
  248. package/src/notifications/notification-parts.tsx +83 -0
  249. package/src/notifications/notification-types-setting.tsx +309 -0
  250. package/src/notifications/notification-types.ts +80 -0
  251. package/src/notifications/notifications-labels.ts +238 -0
  252. package/src/notifications/push-device-setting.tsx +224 -0
  253. package/src/notifications/push-devices-setting.tsx +306 -0
  254. package/src/notifications/quiet-hours-setting.tsx +185 -0
  255. package/src/notifications/time-zone-select.tsx +126 -0
  256. package/src/notifications/unsubscribe-panel.tsx +203 -0
  257. package/src/notifications/use-push-device.ts +392 -0
  258. package/src/rhf/fields.tsx +77 -29
  259. package/src/settings/device-reset-setting-labels.ts +20 -0
  260. package/src/settings/device-reset-setting.tsx +119 -0
  261. package/src/shell/app-shell.tsx +8 -0
  262. package/src/theme/contrast-tokens.ts +106 -12
  263. package/src/theme/contrast.ts +30 -4
  264. package/src/theme/palette-derive.ts +68 -7
  265. package/src/theme/palette-presets.ts +9 -6
  266. package/src/theme/palette-store.ts +12 -0
  267. package/src/theme/text-size.ts +8 -0
  268. package/src/theme/theme-store.ts +10 -0
  269. package/tokens.css +38 -4
@@ -0,0 +1,224 @@
1
+ import { useState } from "react";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+
4
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
5
+ import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
6
+ import { cn } from "../lib/cn";
7
+ import { AlertBanner } from "../components/alert-banner";
8
+ import { Button } from "../components/button";
9
+ import { Card } from "../components/card";
10
+ import { DialogFrame } from "../components/dialog-frame";
11
+ import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
12
+ import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
13
+ import { SettingsCardTitle } from "../settings/settings-heading";
14
+ import { useMounted } from "../account/account-parts";
15
+ import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
16
+ import type { NotificationsLabels } from "./notifications-labels";
17
+ import { PushDeviceError } from "./use-push-device";
18
+ import type { PushDevice } from "./use-push-device";
19
+
20
+ /** Which action failed — {@link PushDeviceSettingProps.describeError}'s second argument. */
21
+ export type PushDeviceAction = "enable" | "disable" | "test";
22
+
23
+ export interface PushDeviceSettingProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
24
+ /** This browser's push, from {@link usePushDevice}. */
25
+ push: PushDevice;
26
+ /** The app's name, for the iOS line and the explainer. */
27
+ appName: string;
28
+ /** The app's own words for a failure, or `undefined` for the kit's. The kit already
29
+ * has words for a missing service worker and an unanswered push service. */
30
+ describeError?: (error: unknown, action: PushDeviceAction) => ReactNode | undefined;
31
+ labels?: LabelOverride<NotificationsLabels>;
32
+ }
33
+
34
+ /** What the line under the buttons says. */
35
+ type Outcome = { tone: "status" | "warning" | "danger"; text: ReactNode } | null;
36
+
37
+ /**
38
+ * Push on THIS device (docs/notifications-harmonization.md §2.2, §2.7, §4.2): keksdose's
39
+ * push block, as a card of its own. Registering the device is the switch — push has no
40
+ * account switch. One state at a time, in keksdose's order
41
+ * (`notifications-card.tsx:333-402`):
42
+ *
43
+ * 1. the browser has no push;
44
+ * 2. iOS outside the installed app (iOS delivers only to the installed app);
45
+ * 3. push is not set up on the server (no VAPID keys);
46
+ * 4. notifications blocked in the browser;
47
+ * 5. on or off — with "another account" when this browser's subscription is someone
48
+ * else's — and "Turn on notifications", or "Send a test" and "Turn off".
49
+ *
50
+ * **The explainer comes before the browser's one prompt**: turning on while the
51
+ * permission is still `"default"` opens a dialog first (what the notifications are for,
52
+ * that the browser asks next); with it granted, it goes straight on. A test that reached
53
+ * no device says so (`testNone`) — a 404/410 removes the row on the server while this
54
+ * browser still holds its subscription. Outcomes are a line under the buttons; nothing
55
+ * toasts.
56
+ */
57
+ export function PushDeviceSetting({
58
+ push,
59
+ appName,
60
+ describeError,
61
+ labels: labelsProp,
62
+ className,
63
+ ...rest
64
+ }: PushDeviceSettingProps) {
65
+ const labels = useNotificationsLabels(labelsProp);
66
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
67
+ const [explainer, setExplainer] = useState(false);
68
+ const [running, setRunning] = useState<PushDeviceAction | null>(null);
69
+ const [outcome, setOutcome] = useState<Outcome>(null);
70
+ const mounted = useMounted();
71
+
72
+ const failureOf = (error: unknown, action: PushDeviceAction, fallback: string): ReactNode => {
73
+ if (error instanceof PushDeviceError) {
74
+ return error.code === "no_service_worker" ? labels.noServiceWorker : labels.pushTimeout;
75
+ }
76
+ return describeFailure(describeError?.(error, action), fallback);
77
+ };
78
+
79
+ const run = <T,>(action: PushDeviceAction, call: () => Promise<T>, done: (value: T) => Outcome, fallback: string) => {
80
+ if (running) return;
81
+ setRunning(action);
82
+ setOutcome(null);
83
+ call().then(
84
+ (value) => {
85
+ if (!mounted.current) return;
86
+ setRunning(null);
87
+ setOutcome(done(value));
88
+ },
89
+ (error: unknown) => {
90
+ if (!mounted.current) return;
91
+ setRunning(null);
92
+ setOutcome({ tone: "danger", text: failureOf(error, action, fallback) });
93
+ },
94
+ );
95
+ };
96
+
97
+ const enable = () =>
98
+ run(
99
+ "enable",
100
+ () => push.enable(),
101
+ (ok): Outcome => (ok ? null : { tone: "danger", text: labels.enableFailed }),
102
+ labels.enableFailed,
103
+ );
104
+ const disable = () => run("disable", () => push.disable(), () => null, labels.failed);
105
+ const test = () =>
106
+ run(
107
+ "test",
108
+ () => push.sendTest(),
109
+ ({ sent }): Outcome =>
110
+ sent > 0 ? { tone: "status", text: labels.testSent(sent) } : { tone: "warning", text: labels.testNone },
111
+ labels.testFailed,
112
+ );
113
+
114
+ const turnOn = () => {
115
+ if (push.permission === "granted") enable();
116
+ else setExplainer(true);
117
+ };
118
+
119
+ let state: ReactNode;
120
+ if (!push.supported) {
121
+ state = <p className={CARD_DESCRIPTION_CLASS}>{labels.unsupported}</p>;
122
+ } else if (push.ios && !push.installed) {
123
+ state = <p className={CARD_DESCRIPTION_CLASS}>{labels.iosInstall(appName)}</p>;
124
+ } else if (push.configured === false) {
125
+ state = <p className={CARD_DESCRIPTION_CLASS}>{labels.notConfigured}</p>;
126
+ } else if (push.permission === "denied") {
127
+ state = <p className="text-xs text-warning">{labels.blocked}</p>;
128
+ } else {
129
+ const busy = push.busy || running !== null;
130
+ state = (
131
+ <>
132
+ <p className={CARD_DESCRIPTION_CLASS} data-push-state={push.subscribed ? "on" : "off"}>
133
+ {push.subscribed ? labels.on : labels.off}
134
+ </p>
135
+ {push.foreignSubscription && <p className="text-xs text-warning">{labels.otherAccount}</p>}
136
+ <div className="flex flex-wrap gap-2">
137
+ {push.subscribed ? (
138
+ <>
139
+ <Button
140
+ type="button"
141
+ size="sm"
142
+ variant="secondary"
143
+ commit={COMMIT_EXCEPT_BILLING}
144
+ pending={running === "test"}
145
+ disabled={busy && running !== "test"}
146
+ onClick={test}
147
+ >
148
+ {labels.sendTest}
149
+ </Button>
150
+ <Button
151
+ type="button"
152
+ size="sm"
153
+ variant="ghost"
154
+ commit={COMMIT_EXCEPT_BILLING}
155
+ pending={running === "disable"}
156
+ disabled={busy && running !== "disable"}
157
+ onClick={disable}
158
+ >
159
+ {labels.disable}
160
+ </Button>
161
+ </>
162
+ ) : (
163
+ <Button
164
+ type="button"
165
+ size="sm"
166
+ commit={COMMIT_EXCEPT_BILLING}
167
+ pending={running === "enable"}
168
+ disabled={busy && running !== "enable"}
169
+ onClick={turnOn}
170
+ >
171
+ {labels.enable}
172
+ </Button>
173
+ )}
174
+ </div>
175
+ {outcome?.tone === "status" && (
176
+ <p role="status" className={CARD_DESCRIPTION_CLASS}>
177
+ {outcome.text}
178
+ </p>
179
+ )}
180
+ {outcome?.tone === "warning" && (
181
+ <AlertBanner tone="warning" size="sm" role="status">
182
+ {outcome.text}
183
+ </AlertBanner>
184
+ )}
185
+ {outcome?.tone === "danger" && <FailureLine>{outcome.text}</FailureLine>}
186
+ </>
187
+ );
188
+ }
189
+
190
+ return (
191
+ <Card {...rest} className={cn("p-4 space-y-3", className)}>
192
+ <div>
193
+ <SettingsCardTitle>{labels.pushTitle}</SettingsCardTitle>
194
+ <div className={CARD_DESCRIPTION_CLASS}>{labels.pushDescription}</div>
195
+ </div>
196
+ {state}
197
+ <DialogFrame
198
+ open={explainer}
199
+ onClose={() => setExplainer(false)}
200
+ title={labels.explainerTitle}
201
+ description={labels.explainerBody(appName)}
202
+ actions={(close) => (
203
+ <div className="flex justify-end gap-2">
204
+ <Button type="button" variant="ghost" onClick={close}>
205
+ {common.cancel}
206
+ </Button>
207
+ <Button
208
+ type="button"
209
+ onClick={() => {
210
+ setExplainer(false);
211
+ // Only now the browser's one permission prompt.
212
+ enable();
213
+ }}
214
+ >
215
+ {labels.explainerContinue}
216
+ </Button>
217
+ </div>
218
+ )}
219
+ >
220
+ <p className={CARD_DESCRIPTION_CLASS}>{labels.explainerHint}</p>
221
+ </DialogFrame>
222
+ </Card>
223
+ );
224
+ }
@@ -0,0 +1,306 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from "react";
3
+ import { MonitorSmartphone } from "lucide-react";
4
+
5
+ import { useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
6
+ import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
7
+ import { cn } from "../lib/cn";
8
+ import { formatDate } from "../lib/format";
9
+ import { Button } from "../components/button";
10
+ import { Card } from "../components/card";
11
+ import { Chip } from "../components/chip";
12
+ import { Skeleton } from "../components/skeleton";
13
+ import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
14
+ import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
15
+ import { SettingsCardTitle } from "../settings/settings-heading";
16
+ import { settle, useMounted } from "../account/account-parts";
17
+ import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
18
+ import type { NotificationsLabels } from "./notifications-labels";
19
+ import type { PushDeviceItem } from "./notification-types";
20
+ import { notifyPushSubscriptionChanged } from "./use-push-device";
21
+
22
+ /**
23
+ * A name for a stored user agent: "Chrome · Android", "Firefox · Windows" (keksdose
24
+ * `push-devices-card.tsx:19-45`). Best effort: an agent it does not recognise comes back
25
+ * as it is, cut at 60 characters so a token soup cannot blow up the row; `null` for none.
26
+ * The order matters — Edge and Opera say "Chrome" too, and Chrome says "Safari".
27
+ */
28
+ export function describeUserAgent(ua: string | null): string | null {
29
+ if (!ua) return null;
30
+ const browser = /edg(?:e|a|ios)?\//i.test(ua)
31
+ ? "Edge"
32
+ : /opr\/|opera/i.test(ua)
33
+ ? "Opera"
34
+ : /firefox|fxios/i.test(ua)
35
+ ? "Firefox"
36
+ : /chrome|crios/i.test(ua)
37
+ ? "Chrome"
38
+ : /safari/i.test(ua)
39
+ ? "Safari"
40
+ : null;
41
+ const os = /windows/i.test(ua)
42
+ ? "Windows"
43
+ : /android/i.test(ua)
44
+ ? "Android"
45
+ : /iphone|ipad|ipod/i.test(ua)
46
+ ? "iOS"
47
+ : /mac os x|macintosh/i.test(ua)
48
+ ? "macOS"
49
+ : /linux/i.test(ua)
50
+ ? "Linux"
51
+ : null;
52
+ if (!browser && !os) return ua.length > 60 ? `${ua.slice(0, 60)}…` : ua;
53
+ return [browser, os].filter(Boolean).join(" · ");
54
+ }
55
+
56
+ export interface PushDevicesSettingProps<Id extends string | number = string>
57
+ extends Omit<ComponentPropsWithRef<"div">, "children"> {
58
+ /** `GET /notifications/devices`, oldest first; `undefined` while the first load runs. */
59
+ devices: readonly PushDeviceItem<Id>[] | undefined;
60
+ loading?: boolean;
61
+ /** This browser's endpoint (`usePushDevice().currentEndpoint`): its row says "This
62
+ * device", and removing it also unsubscribes the browser. */
63
+ currentEndpoint?: string | null;
64
+ /** `DELETE /notifications/devices/{id}`. Asked first; refresh `devices` once it
65
+ * resolves. */
66
+ onRemove: (id: Id) => Promise<unknown> | void;
67
+ /** The dates' text. Default: the provider's locale, medium date. */
68
+ formatDate?: (date: Date) => string;
69
+ /** Overrides the provider's locale for the default format. */
70
+ locale?: string;
71
+ /** The app's own words for a failed removal, or `undefined` for the kit's `failed`. */
72
+ describeError?: (error: unknown) => ReactNode | undefined;
73
+ labels?: LabelOverride<NotificationsLabels>;
74
+ }
75
+
76
+ /** Tear down this browser's subscription after its server row went: otherwise the
77
+ * browser still holds one, and the push card would say "on" for a device nothing can
78
+ * reach. Best effort — the row is already gone. */
79
+ async function unsubscribeThisBrowser(): Promise<void> {
80
+ try {
81
+ const registration = await navigator.serviceWorker?.getRegistration();
82
+ const subscription = await registration?.pushManager.getSubscription();
83
+ await subscription?.unsubscribe();
84
+ } catch {
85
+ /* the server row is gone either way */
86
+ }
87
+ notifyPushSubscriptionChanged();
88
+ }
89
+
90
+ /**
91
+ * Every device that gets push for the account (docs/notifications-harmonization.md §3.5,
92
+ * §4.2) — keksdose's devices card: the browser and system read from the stored user
93
+ * agent, "This device" on this browser's row, when it was added and last delivered to,
94
+ * the failures in a row in the warning tone, and "Remove".
95
+ *
96
+ * **Remove asks first, in the row**, as `PasskeysSetting` does: the card must work in an
97
+ * app without a `ConfirmProvider`, and the question belongs beside the device it names.
98
+ * Focus moves to Cancel. **Removing this device** also unsubscribes the browser and fires
99
+ * `PUSH_SUBSCRIPTION_CHANGED` (keksdose `push-devices-card.tsx:83-97`), so the push card
100
+ * stops saying "on".
101
+ *
102
+ * Under a write lock Remove is a `commit` control exempt from billing.
103
+ */
104
+ export function PushDevicesSetting<Id extends string | number = string>({
105
+ devices,
106
+ loading,
107
+ currentEndpoint,
108
+ onRemove,
109
+ formatDate: formatDateProp,
110
+ locale: localeProp,
111
+ describeError,
112
+ labels: labelsProp,
113
+ className,
114
+ ...rest
115
+ }: PushDevicesSettingProps<Id>) {
116
+ const labels = useNotificationsLabels(labelsProp);
117
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
118
+ const locale = useKitLocale(localeProp);
119
+ const [asking, setAsking] = useState<Id | null>(null);
120
+ const [busyId, setBusyId] = useState<Id | null>(null);
121
+ const [failure, setFailure] = useState<ReactNode>(null);
122
+ const mounted = useMounted();
123
+
124
+ const dateText = (iso: string): string => {
125
+ const date = new Date(iso);
126
+ if (Number.isNaN(date.getTime())) return iso;
127
+ return formatDateProp ? formatDateProp(date) : formatDate(date, "medium", { locale });
128
+ };
129
+
130
+ const remove = (device: PushDeviceItem<Id>) => {
131
+ if (busyId !== null) return;
132
+ setBusyId(device.id);
133
+ setFailure(null);
134
+ const isThis = Boolean(currentEndpoint) && device.endpoint === currentEndpoint;
135
+ settle(() => onRemove(device.id))
136
+ .then(async () => {
137
+ if (isThis) await unsubscribeThisBrowser();
138
+ if (!mounted.current) return;
139
+ setBusyId(null);
140
+ setAsking(null);
141
+ })
142
+ .catch((error: unknown) => {
143
+ if (!mounted.current) return;
144
+ setBusyId(null);
145
+ setFailure(describeFailure(describeError?.(error), labels.failed));
146
+ });
147
+ };
148
+
149
+ let body: ReactNode;
150
+ if (loading || devices === undefined) {
151
+ body = <Skeleton lines={2} label={labels.devicesLoading} />;
152
+ } else if (devices.length === 0) {
153
+ body = <p className={CARD_DESCRIPTION_CLASS}>{labels.devicesEmpty}</p>;
154
+ } else {
155
+ body = (
156
+ <ul aria-label={labels.devicesTitle} className="divide-y divide-subtle">
157
+ {devices.map((device) => (
158
+ <DeviceRow
159
+ key={device.id}
160
+ device={device}
161
+ name={describeUserAgent(device.user_agent) ?? labels.unknownDevice}
162
+ current={Boolean(currentEndpoint) && device.endpoint === currentEndpoint}
163
+ added={dateText(device.created_at)}
164
+ lastDelivery={device.last_success_at ? dateText(device.last_success_at) : null}
165
+ asking={asking === device.id}
166
+ busy={busyId === device.id}
167
+ blocked={busyId !== null && busyId !== device.id}
168
+ onAsk={() => setAsking(device.id)}
169
+ onCancel={() => setAsking(null)}
170
+ onConfirm={() => remove(device)}
171
+ labels={labels}
172
+ cancelLabel={common.cancel}
173
+ />
174
+ ))}
175
+ </ul>
176
+ );
177
+ }
178
+
179
+ return (
180
+ <Card {...rest} className={cn("p-4 space-y-3", className)}>
181
+ <div>
182
+ <SettingsCardTitle>{labels.devicesTitle}</SettingsCardTitle>
183
+ <div className={CARD_DESCRIPTION_CLASS}>{labels.devicesDescription}</div>
184
+ </div>
185
+ {body}
186
+ <FailureLine>{failure}</FailureLine>
187
+ </Card>
188
+ );
189
+ }
190
+
191
+ function DeviceRow<Id extends string | number>({
192
+ device,
193
+ name,
194
+ current,
195
+ added,
196
+ lastDelivery,
197
+ asking,
198
+ busy,
199
+ blocked,
200
+ onAsk,
201
+ onCancel,
202
+ onConfirm,
203
+ labels,
204
+ cancelLabel,
205
+ }: {
206
+ device: PushDeviceItem<Id>;
207
+ name: string;
208
+ current: boolean;
209
+ added: string;
210
+ lastDelivery: string | null;
211
+ asking: boolean;
212
+ busy: boolean;
213
+ blocked: boolean;
214
+ onAsk: () => void;
215
+ onCancel: () => void;
216
+ onConfirm: () => void;
217
+ labels: NotificationsLabels;
218
+ cancelLabel: string;
219
+ }) {
220
+ const cancelRef = useRef<HTMLButtonElement>(null);
221
+ const removeRef = useRef<HTMLButtonElement>(null);
222
+ // Which way the confirm closed: back to Remove, so focus does not fall to <body>.
223
+ const [returning, setReturning] = useState(false);
224
+ useEffect(() => {
225
+ if (asking) cancelRef.current?.focus();
226
+ else if (returning) removeRef.current?.focus();
227
+ }, [asking, returning]);
228
+
229
+ const leave = () => {
230
+ setReturning(true);
231
+ onCancel();
232
+ };
233
+ const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {
234
+ if (event.key === "Escape") {
235
+ event.preventDefault();
236
+ event.stopPropagation();
237
+ leave();
238
+ }
239
+ };
240
+
241
+ const row = { "data-push-device-id": String(device.id), "data-current": current || undefined };
242
+
243
+ if (asking) {
244
+ return (
245
+ <li {...row} className="space-y-2 py-2">
246
+ <p role="alert" className="text-sm">
247
+ {labels.removePrompt}
248
+ </p>
249
+ <div className="flex flex-wrap gap-2">
250
+ <Button
251
+ type="button"
252
+ size="sm"
253
+ variant="danger"
254
+ commit={COMMIT_EXCEPT_BILLING}
255
+ pending={busy}
256
+ aria-label={labels.removeItem(name)}
257
+ onClick={onConfirm}
258
+ onKeyDown={escapeLeaves}
259
+ >
260
+ {labels.remove}
261
+ </Button>
262
+ <Button ref={cancelRef} type="button" size="sm" variant="ghost" onClick={leave} onKeyDown={escapeLeaves}>
263
+ {cancelLabel}
264
+ </Button>
265
+ </div>
266
+ </li>
267
+ );
268
+ }
269
+
270
+ return (
271
+ <li {...row} className="flex items-center gap-3 py-2">
272
+ <MonitorSmartphone aria-hidden className="size-4 shrink-0 text-muted" />
273
+ <div className="min-w-0 flex-1">
274
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
275
+ <span className="min-w-0 break-words text-sm font-medium">{name}</span>
276
+ {current && (
277
+ <Chip size="sm" tone="brand">
278
+ {labels.thisDevice}
279
+ </Chip>
280
+ )}
281
+ </div>
282
+ <div className={CARD_DESCRIPTION_CLASS}>
283
+ {labels.added(added)}
284
+ {" · "}
285
+ {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}
286
+ </div>
287
+ {device.failure_count > 0 && <div className="text-xs text-warning">{labels.failures(device.failure_count)}</div>}
288
+ </div>
289
+ <Button
290
+ ref={removeRef}
291
+ type="button"
292
+ size="sm"
293
+ variant="ghost"
294
+ commit={COMMIT_EXCEPT_BILLING}
295
+ disabled={blocked}
296
+ aria-label={labels.removeItem(name)}
297
+ onClick={() => {
298
+ setReturning(false);
299
+ onAsk();
300
+ }}
301
+ >
302
+ {labels.remove}
303
+ </Button>
304
+ </li>
305
+ );
306
+ }