@eifi1/ui-kit 0.26.0 → 0.27.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 (158) hide show
  1. package/README.md +15 -4
  2. package/dist/chart.d.ts +13 -9
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +13 -9
  5. package/dist/components/button-group.d.ts +13 -9
  6. package/dist/components/calculator.d.ts +13 -9
  7. package/dist/components/confirm-dialog.d.ts +13 -9
  8. package/dist/components/copy-button.d.ts +10 -6
  9. package/dist/components/danger-confirm.d.ts +13 -9
  10. package/dist/components/data-table-cells.d.ts +1 -1
  11. package/dist/components/data-table-filter-popover.d.ts +1 -1
  12. package/dist/components/data-table-filters.d.ts +1 -1
  13. package/dist/components/data-table-labels.d.ts +1 -1
  14. package/dist/components/data-table-pagination.d.ts +1 -1
  15. package/dist/components/data-table.d.ts +1 -1
  16. package/dist/components/data-table.js.map +1 -1
  17. package/dist/components/facing-pair.d.ts +13 -9
  18. package/dist/components/file-button.d.ts +13 -9
  19. package/dist/components/file-dropzone.d.ts +13 -9
  20. package/dist/components/form-actions.d.ts +13 -9
  21. package/dist/components/iban-input.d.ts +13 -9
  22. package/dist/components/language-select.d.ts +13 -9
  23. package/dist/components/money-field.d.ts +13 -9
  24. package/dist/components/number-field.d.ts +13 -9
  25. package/dist/components/number-input.d.ts +13 -9
  26. package/dist/components/numpad-sheet.d.ts +13 -9
  27. package/dist/components/phone-input.d.ts +13 -9
  28. package/dist/components/series-chart.d.ts +13 -9
  29. package/dist/components/settings-fields.d.ts +13 -9
  30. package/dist/components/share-card.d.ts +13 -9
  31. package/dist/components/text-link.d.ts +13 -9
  32. package/dist/components/time-input.d.ts +13 -9
  33. package/dist/components/ui.d.ts +10 -6
  34. package/dist/components/use-table-state.d.ts +1 -1
  35. package/dist/{data-table-labels-Cdn1QS2k.d.ts → data-table-labels-DUELeXpa.d.ts} +5 -0
  36. package/dist/data-table.d.ts +1 -1
  37. package/dist/feedback/feedback-attachment.d.ts +13 -9
  38. package/dist/feedback/feedback-attachment.js +3 -3
  39. package/dist/feedback/feedback-attachment.js.map +1 -1
  40. package/dist/feedback/feedback-capture.d.ts +71 -0
  41. package/dist/feedback/feedback-capture.js +194 -0
  42. package/dist/feedback/feedback-capture.js.map +1 -0
  43. package/dist/feedback/feedback-context.d.ts +71 -0
  44. package/dist/feedback/feedback-context.js +96 -0
  45. package/dist/feedback/feedback-context.js.map +1 -0
  46. package/dist/feedback/feedback-crash.d.ts +276 -0
  47. package/dist/feedback/feedback-crash.js +225 -0
  48. package/dist/feedback/feedback-crash.js.map +1 -0
  49. package/dist/feedback/feedback-dialog.d.ts +13 -9
  50. package/dist/feedback/feedback-dialog.js +5 -3
  51. package/dist/feedback/feedback-dialog.js.map +1 -1
  52. package/dist/feedback/feedback-inbox.d.ts +13 -9
  53. package/dist/feedback/feedback-inbox.js +89 -49
  54. package/dist/feedback/feedback-inbox.js.map +1 -1
  55. package/dist/feedback/feedback-labels.d.ts +71 -0
  56. package/dist/feedback/feedback-labels.js +48 -0
  57. package/dist/feedback/feedback-labels.js.map +1 -0
  58. package/dist/feedback/feedback-menu.d.ts +71 -0
  59. package/dist/feedback/feedback-menu.js +61 -0
  60. package/dist/feedback/feedback-menu.js.map +1 -0
  61. package/dist/feedback/feedback-record.d.ts +71 -0
  62. package/dist/feedback/feedback-record.js +104 -0
  63. package/dist/feedback/feedback-record.js.map +1 -0
  64. package/dist/feedback/feedback-row-detail.d.ts +71 -0
  65. package/dist/feedback/feedback-row-detail.js +437 -0
  66. package/dist/feedback/feedback-row-detail.js.map +1 -0
  67. package/dist/feedback/feedback-status-undo.d.ts +71 -0
  68. package/dist/feedback/feedback-status-undo.js +40 -0
  69. package/dist/feedback/feedback-status-undo.js.map +1 -0
  70. package/dist/feedback/feedback-submit.d.ts +182 -0
  71. package/dist/feedback/feedback-submit.js +101 -0
  72. package/dist/feedback/feedback-submit.js.map +1 -0
  73. package/dist/feedback/feedback-swipe.d.ts +152 -0
  74. package/dist/feedback/feedback-swipe.js +73 -0
  75. package/dist/feedback/feedback-swipe.js.map +1 -0
  76. package/dist/feedback/feedback-table.d.ts +71 -0
  77. package/dist/feedback/feedback-table.js +357 -0
  78. package/dist/feedback/feedback-table.js.map +1 -0
  79. package/dist/feedback/feedback-thread.d.ts +13 -9
  80. package/dist/feedback.d.ts +17 -9
  81. package/dist/feedback.js +11 -0
  82. package/dist/feedback.js.map +1 -1
  83. package/dist/hooks/use-file-drop.d.ts +13 -9
  84. package/dist/i18n/defaults.d.ts +13 -9
  85. package/dist/i18n/defaults.js +16 -0
  86. package/dist/i18n/defaults.js.map +1 -1
  87. package/dist/i18n/german.d.ts +13 -9
  88. package/dist/i18n/german.js +91 -5
  89. package/dist/i18n/german.js.map +1 -1
  90. package/dist/i18n/kit-labels.d.ts +7 -3
  91. package/dist/i18n/kit-labels.js.map +1 -1
  92. package/dist/i18n/languages.d.ts +13 -9
  93. package/dist/i18n/locales/de-CH.d.ts +13 -9
  94. package/dist/i18n/locales/en.d.ts +13 -9
  95. package/dist/i18n/locales/en.js +10 -1
  96. package/dist/i18n/locales/en.js.map +1 -1
  97. package/dist/i18n/locales/es.d.ts +13 -9
  98. package/dist/i18n/locales/es.js +89 -5
  99. package/dist/i18n/locales/es.js.map +1 -1
  100. package/dist/i18n/locales/fr.d.ts +13 -9
  101. package/dist/i18n/locales/fr.js +89 -5
  102. package/dist/i18n/locales/fr.js.map +1 -1
  103. package/dist/i18n/locales/hu.d.ts +13 -9
  104. package/dist/i18n/locales/hu.js +90 -5
  105. package/dist/i18n/locales/hu.js.map +1 -1
  106. package/dist/i18n/locales/it.d.ts +13 -9
  107. package/dist/i18n/locales/it.js +89 -5
  108. package/dist/i18n/locales/it.js.map +1 -1
  109. package/dist/i18n/locales/zh.d.ts +13 -9
  110. package/dist/i18n/locales/zh.js +89 -5
  111. package/dist/i18n/locales/zh.js.map +1 -1
  112. package/dist/i18n/review.d.ts +16 -11
  113. package/dist/i18n/review.js +11 -0
  114. package/dist/i18n/review.js.map +1 -1
  115. package/dist/i18n/swiss.js +3 -1
  116. package/dist/i18n/swiss.js.map +1 -1
  117. package/dist/index.d.ts +6 -2
  118. package/dist/index.js +11 -0
  119. package/dist/index.js.map +1 -1
  120. package/dist/{feedback-BXKH-ToU.d.ts → kit-labels-DQg82eHB.d.ts} +1275 -25
  121. package/dist/rhf/fields.d.ts +13 -9
  122. package/dist/rhf/form.d.ts +13 -9
  123. package/dist/rhf.d.ts +13 -9
  124. package/dist/shell/app-shell.d.ts +13 -9
  125. package/dist/shell/top-bar-brand.d.ts +13 -9
  126. package/dist/shell.d.ts +11 -11
  127. package/dist/wizard/stepper-nav.d.ts +13 -9
  128. package/dist/wizard.d.ts +13 -9
  129. package/package.json +6 -1
  130. package/src/components/data-table.tsx +5 -0
  131. package/src/feedback/feedback-attachment.tsx +19 -3
  132. package/src/feedback/feedback-capture.ts +285 -0
  133. package/src/feedback/feedback-context.tsx +217 -0
  134. package/src/feedback/feedback-crash.ts +547 -0
  135. package/src/feedback/feedback-dialog.tsx +29 -6
  136. package/src/feedback/feedback-inbox.tsx +131 -27
  137. package/src/feedback/feedback-labels.ts +127 -0
  138. package/src/feedback/feedback-menu.tsx +132 -0
  139. package/src/feedback/feedback-record.ts +345 -0
  140. package/src/feedback/feedback-row-detail.tsx +825 -0
  141. package/src/feedback/feedback-status-undo.ts +111 -0
  142. package/src/feedback/feedback-submit.tsx +224 -0
  143. package/src/feedback/feedback-swipe.tsx +170 -0
  144. package/src/feedback/feedback-table.tsx +757 -0
  145. package/src/feedback.ts +13 -0
  146. package/src/i18n/defaults.ts +16 -0
  147. package/src/i18n/german.ts +98 -5
  148. package/src/i18n/kit-labels.tsx +20 -0
  149. package/src/i18n/locales/en.ts +15 -1
  150. package/src/i18n/locales/es.ts +96 -5
  151. package/src/i18n/locales/fr.ts +95 -5
  152. package/src/i18n/locales/hu.ts +94 -5
  153. package/src/i18n/locales/it.ts +97 -5
  154. package/src/i18n/locales/zh.ts +90 -5
  155. package/src/i18n/review.ts +14 -2
  156. package/src/i18n/swiss.ts +5 -1
  157. package/src/index.ts +13 -0
  158. package/tokens.css +4 -0
@@ -0,0 +1,111 @@
1
+ import { useCallback } from "react";
2
+ import { toast } from "../components/toast";
3
+ import type { FeedbackStatus } from "./feedback-inbox";
4
+ import {
5
+ useFeedbackStatusLabels,
6
+ useFeedbackToastLabels,
7
+ type FeedbackStatusLabels,
8
+ type FeedbackToastLabels,
9
+ } from "./feedback-labels";
10
+ import type { FeedbackRecord } from "./feedback-record";
11
+
12
+ /**
13
+ * A status change that can be taken back — §4.5 of the feedback contract, lifted from
14
+ * keksdose's `changeStatusUndoably` (`frontend/src/features/feedback/feedback-page.tsx`,
15
+ * dev #587: *"Just clicked somewhere and a feedback row disappeared without knowing what
16
+ * I set as new status."*).
17
+ *
18
+ * An inbox is usually filtered by status, so a row whose new status falls outside the
19
+ * filter leaves the table the moment the change lands, with nothing on screen saying what
20
+ * it became. The toast says it, names the report, and offers the old status back for 8 s
21
+ * (the window keksdose's transactions Undo uses, feedback #184). Undo PATCHes the old
22
+ * status and confirms that too. kastlan and Kurvenschmiede had no Undo at all.
23
+ */
24
+
25
+ /** The one PATCH a status change is: `PATCH /feedback/{id}` with `{ status }`. */
26
+ export interface FeedbackStatusPatch {
27
+ id: number;
28
+ status: FeedbackStatus;
29
+ }
30
+
31
+ /**
32
+ * The page's status mutation. **TanStack Query's `mutate` fits as it stands** — pass
33
+ * `updateMutation.mutate` (stable across renders), not `mutateAsync`. A hand-written
34
+ * one sends the PATCH and calls `onSuccess` once it has landed, and only then.
35
+ *
36
+ * Failure is the mutation's own business: its `onError` toasts the server's `detail`,
37
+ * else `feedbackToast.updateFailed` (§4.4) — this hook adds no second error toast.
38
+ */
39
+ export type FeedbackStatusMutate = (
40
+ patch: FeedbackStatusPatch,
41
+ callbacks: { onSuccess: () => void },
42
+ ) => void;
43
+
44
+ /** What the change needs to know about the row: which one, what it is called in the
45
+ * toast, and where it stood (the status Undo goes back to). */
46
+ export type FeedbackStatusRow = Pick<FeedbackRecord, "id" | "title" | "status">;
47
+
48
+ /** Set `row` to `status`, with the Undo toast. A no-op when it is already there. */
49
+ export type FeedbackStatusChange = (row: FeedbackStatusRow, status: FeedbackStatus) => void;
50
+
51
+ export interface FeedbackStatusUndoOptions {
52
+ /** Over the `feedbackToast` namespace (the toast's words). */
53
+ labels?: Partial<FeedbackToastLabels>;
54
+ /** Over the `feedbackStatus` namespace (the status names inside them). */
55
+ statusLabels?: Partial<FeedbackStatusLabels>;
56
+ }
57
+
58
+ /** How long the Undo stays reachable: 8 s, keksdose's window (feedback #184). */
59
+ export const FEEDBACK_UNDO_DURATION = 8000;
60
+
61
+ /**
62
+ * The undoable status change — for the table's status cell, the row detail's pills and
63
+ * the phone swipes alike (§4.5, §7.10: swipes included, Marcel 2026-10-04 — keksdose's
64
+ * swipe commits go through its `changeStatusUndoably` too). Wire every one of them
65
+ * through the function this returns:
66
+ *
67
+ * ```tsx
68
+ * const update = useMutation({ mutationFn: api.updateFeedback, onError: … });
69
+ * const changeStatus = useFeedbackStatusUndo(update.mutate);
70
+ * <FeedbackStatusTransitions … onPick={(status) => changeStatus(row, status)} />
71
+ * ```
72
+ *
73
+ * Give it the PAGE's mutation, not one a row owns: TanStack only calls per-call
74
+ * callbacks while the observer that started the mutation is mounted, and a row the new
75
+ * status filters out unmounts before the PATCH answers — the toast would never come.
76
+ *
77
+ * The flow: PATCH `{ id, status }` → on success a success toast
78
+ * `statusChanged(newName, title)` with the action `statusUndo`, 8 s → Undo PATCHes
79
+ * `{ id, status: previous }` → on success `statusRestored(previousName, title)`.
80
+ */
81
+ export function useFeedbackStatusUndo(
82
+ mutate: FeedbackStatusMutate,
83
+ { labels, statusLabels }: FeedbackStatusUndoOptions = {},
84
+ ): FeedbackStatusChange {
85
+ const words = useFeedbackToastLabels(labels);
86
+ const names = useFeedbackStatusLabels(statusLabels);
87
+ return useCallback<FeedbackStatusChange>(
88
+ (row, status) => {
89
+ const { id, title, status: previous } = row;
90
+ if (status === previous) return;
91
+ const nameOf = (value: FeedbackStatus) => names[value] ?? value;
92
+ mutate(
93
+ { id, status },
94
+ {
95
+ onSuccess: () => {
96
+ toast.undo(words.statusChanged(nameOf(status), title), {
97
+ label: words.statusUndo,
98
+ duration: FEEDBACK_UNDO_DURATION,
99
+ onUndo: () =>
100
+ mutate(
101
+ { id, status: previous },
102
+ { onSuccess: () => void toast.success(words.statusRestored(nameOf(previous), title)) },
103
+ ),
104
+ });
105
+ },
106
+ },
107
+ );
108
+ },
109
+ [mutate, words, names],
110
+ );
111
+ }
@@ -0,0 +1,224 @@
1
+ import { useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { toast } from "../components/toast";
4
+ import { DEFAULT_MAX_ATTACHMENTS } from "./feedback-attachment";
5
+ import { captureAppScreenshot } from "./feedback-capture";
6
+ import { FeedbackContextBox, feedbackContext, type FeedbackSubmitter } from "./feedback-context";
7
+ import { FeedbackDialog, type FeedbackMultipleSubmission } from "./feedback-dialog";
8
+ import type { FeedbackCategory } from "./feedback-inbox";
9
+ import { useFeedbackCategoryLabels, useFeedbackToastLabels } from "./feedback-labels";
10
+ import {
11
+ FEEDBACK_ATTACHMENT_ACCEPT,
12
+ FEEDBACK_PICKABLE_CATEGORIES,
13
+ type FeedbackContext,
14
+ type FeedbackEnvironment,
15
+ } from "./feedback-record";
16
+
17
+ /**
18
+ * `FeedbackCreate` — the body of `POST /feedback` (keksdose `schemas/feedback.py:25`, §3.4
19
+ * of docs/feedback-harmonization.md), as {@link useFeedbackSubmit} hands it to the app's
20
+ * `create`.
21
+ */
22
+ export interface FeedbackCreatePayload {
23
+ /** Trimmed; never blank (the dialog will not send a subject of spaces). */
24
+ title: string;
25
+ /** `""` when the reporter wrote nothing or only whitespace — the server stores `""`. */
26
+ body: string;
27
+ /** Never `CRASH` — the server answers that with a 422 (§3.4). */
28
+ category: FeedbackCategory;
29
+ /** §3.2, built by `feedbackContext` from what the context box showed. */
30
+ context: FeedbackContext;
31
+ /** The captured screenshot's upload URL, or `null`. */
32
+ screenshot_url: string | null;
33
+ /** The picked and pasted files' upload URLs, in the order they were added — `[]` when
34
+ * none, as keksdose sends it. At most 5. */
35
+ attachment_urls: string[];
36
+ }
37
+
38
+ export interface UseFeedbackSubmitOptions<R = unknown> {
39
+ /**
40
+ * `POST /feedback/attachments` for ONE file → its URL (§3.3) — the app's authed client.
41
+ * Every file goes up BEFORE the report is created, so a row never names a file that did
42
+ * not arrive (keksdose `use-feedback-dialog.tsx:89`); if any upload rejects, nothing is
43
+ * created and the failure toast says so.
44
+ */
45
+ upload: (file: File) => Promise<string>;
46
+ /** `POST /feedback` with the payload. Whatever it resolves with reaches `onSubmitted`. */
47
+ create: (payload: FeedbackCreatePayload) => Promise<R>;
48
+ /** Who is filing — shown in the context box and sent in `context.user_*`. */
49
+ user?: FeedbackSubmitter | null;
50
+ /** `context.environment` — the app's own reading of which copy it is (keksdose
51
+ * `deployEnvironment()`). */
52
+ environment?: FeedbackEnvironment | (string & {});
53
+ /** `context.version` — the app build (`__APP_VERSION__`). */
54
+ version?: string;
55
+ /** The report was filed: after the "Thanks for the feedback!" toast and the close. The
56
+ * place to invalidate the app's feedback queries. */
57
+ onSubmitted?: (result: R) => void;
58
+ /**
59
+ * The failure toast's text from what `upload` or `create` rejected with — the app's own
60
+ * `extractApiErrorMessage(err, fallback)` (keksdose) / `apiErrorMessage` (Kurvenschmiede),
61
+ * so the server's `detail` wins (§3.7: a 413, a 415, keksdose's demo-session 403). Without
62
+ * it every failure says `feedbackToast.submitFailed`.
63
+ */
64
+ errorMessage?: (error: unknown, fallback: string) => string;
65
+ /**
66
+ * The "Capture screenshot" button's capture. Default {@link captureAppScreenshot} of
67
+ * `#root`; another root is `() => captureAppScreenshot({ rootId: "app" })`. `false`
68
+ * hides the button — for an app that does not install the optional peer
69
+ * `modern-screenshot`. A rejection becomes the `captureFailed` toast.
70
+ */
71
+ capture?: false | (() => Promise<File | null>);
72
+ }
73
+
74
+ export interface FeedbackSubmit {
75
+ /**
76
+ * Open the dialog on `category` — a `FeedbackMenu` row's (§4.2: "default category when
77
+ * opened from a row: that row's"). Without one, or with `CRASH` (never pickable), it
78
+ * opens on `OTHER`, the server's own default (§3.4). Each open starts a fresh form,
79
+ * reads `location.href` for the context box and ticks "Attach current page URL" again.
80
+ */
81
+ open: (category?: FeedbackCategory) => void;
82
+ /** Close it, as Cancel does. */
83
+ close: () => void;
84
+ /** The dialog — render it once, anywhere (it portals). `null` while closed. */
85
+ dialog: ReactNode;
86
+ }
87
+
88
+ /**
89
+ * The feedback submit dialog, wired (0.27.0, docs/feedback-harmonization.md §4.2): the
90
+ * one call that replaces keksdose's `use-feedback-dialog.tsx`, kastlan's `FeedbackButton`
91
+ * and Kurvenschmiede's `use-feedback-dialog.tsx`.
92
+ *
93
+ * ```tsx
94
+ * const feedback = useFeedbackSubmit({
95
+ * upload: (file) => feedbackApi.uploadAttachment(file),
96
+ * create: (payload) => feedbackApi.create(payload),
97
+ * user, environment: deployEnvironment(), version: __APP_VERSION__,
98
+ * errorMessage: extractApiErrorMessage,
99
+ * onSubmitted: () => qc.invalidateQueries({ queryKey: queryKeys.feedback.all }),
100
+ * });
101
+ * <FeedbackMenu onFile={feedback.open} isAdmin={user?.role === "ADMIN"} />
102
+ * {feedback.dialog}
103
+ * ```
104
+ *
105
+ * What it settles, so no app restates it:
106
+ *
107
+ * - `FeedbackDialog` in `attachments="multiple"` with `requireBody={false}` (§7.4): a
108
+ * subject alone is a report. One captured screenshot in its own slot plus up to 5
109
+ * picked or pasted files, `FEEDBACK_ATTACHMENT_ACCEPT` (images, PDF, text), 10 MB each.
110
+ * - The pickable categories — Bug, Idea, Question, Other, in the kit's words. `CRASH` is
111
+ * filed by the error boundary and is never offered (keksdose feedback #160).
112
+ * - The context box, built from the same values as the `context` it sends
113
+ * ({@link FeedbackContextBox} / `feedbackContext`), the URL read when the dialog OPENS.
114
+ * - Upload every file, then create (keksdose `use-feedback-dialog.tsx:89`): the
115
+ * screenshot to `screenshot_url`, the rest to `attachment_urls`, all in parallel.
116
+ * - The toasts, from `feedbackToast`: filed, failed (the server's `detail` through
117
+ * `errorMessage`), a refused file's type, size or count, a failed capture.
118
+ *
119
+ * What stays the app's: the API client, the user, the environment and version, and what
120
+ * to refresh afterwards. No TanStack in here — `create` is a plain promise, so the hook
121
+ * runs in any app; the app's mutation, if it wants one, lives inside `create`.
122
+ *
123
+ * A failure keeps the dialog open with the draft, as keksdose's did. A dialog closed and
124
+ * opened again while a send is still running is NOT closed by that send's success — the
125
+ * new draft is somebody's next report.
126
+ */
127
+ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions<R>): FeedbackSubmit {
128
+ const { upload, create, user, environment, version, onSubmitted, errorMessage, capture } = options;
129
+ const toastText = useFeedbackToastLabels();
130
+ const categoryLabels = useFeedbackCategoryLabels();
131
+ const [isOpen, setIsOpen] = useState(false);
132
+ const [category, setCategory] = useState<FeedbackCategory>("OTHER");
133
+ const [url, setUrl] = useState("");
134
+ const [attachUrl, setAttachUrl] = useState(true);
135
+ const [submitting, setSubmitting] = useState(false);
136
+ // Which opening a send belongs to — see "closed and opened again" above.
137
+ const opening = useRef(0);
138
+ const inFlight = useRef(false);
139
+
140
+ const open = (next?: FeedbackCategory) => {
141
+ opening.current += 1;
142
+ setCategory(next && FEEDBACK_PICKABLE_CATEGORIES.includes(next) ? next : "OTHER");
143
+ setUrl(typeof window === "undefined" ? "" : window.location.href);
144
+ // Fresh every time (Kurvenschmiede use-feedback-dialog.tsx:80): a reporter who left the
145
+ // page out of one idea is not saying the bug report after it is unrelated too.
146
+ setAttachUrl(true);
147
+ setIsOpen(true);
148
+ };
149
+ const close = () => setIsOpen(false);
150
+
151
+ const submit = async (data: FeedbackMultipleSubmission) => {
152
+ if (inFlight.current) return;
153
+ inFlight.current = true;
154
+ setSubmitting(true);
155
+ const from = opening.current;
156
+ let result: R;
157
+ try {
158
+ const [screenshot_url, attachment_urls] = await Promise.all([
159
+ data.screenshot ? upload(data.screenshot) : Promise.resolve(null),
160
+ Promise.all(data.attachments.map((file) => upload(file))),
161
+ ]);
162
+ result = await create({
163
+ title: data.title.trim(),
164
+ body: data.body.trim() ? data.body : "",
165
+ category: data.category as FeedbackCategory,
166
+ context: feedbackContext({ user, attachUrl, url, environment, version }),
167
+ screenshot_url,
168
+ attachment_urls,
169
+ });
170
+ } catch (error) {
171
+ toast.error(errorMessage ? errorMessage(error, toastText.submitFailed) : toastText.submitFailed);
172
+ return;
173
+ } finally {
174
+ inFlight.current = false;
175
+ setSubmitting(false);
176
+ }
177
+ toast.success(toastText.submitted);
178
+ if (opening.current === from) setIsOpen(false);
179
+ onSubmitted?.(result);
180
+ };
181
+
182
+ const onCaptureScreenshot =
183
+ capture === false
184
+ ? undefined
185
+ : async () => {
186
+ try {
187
+ return await (capture ?? (() => captureAppScreenshot()))();
188
+ } catch {
189
+ toast.error(toastText.captureFailed);
190
+ return null;
191
+ }
192
+ };
193
+
194
+ const dialog = isOpen ? (
195
+ <FeedbackDialog
196
+ open
197
+ onClose={close}
198
+ categories={FEEDBACK_PICKABLE_CATEGORIES.map((value) => ({ value, label: categoryLabels[value] }))}
199
+ category={category}
200
+ onCategoryChange={(value) => setCategory(value as FeedbackCategory)}
201
+ submitting={submitting}
202
+ attachments="multiple"
203
+ maxAttachments={DEFAULT_MAX_ATTACHMENTS}
204
+ requireBody={false}
205
+ attachmentAccept={FEEDBACK_ATTACHMENT_ACCEPT}
206
+ onAttachmentError={(kind, info) =>
207
+ toast.error(
208
+ kind === "type"
209
+ ? toastText.attachmentUnsupported
210
+ : kind === "size"
211
+ ? toastText.attachmentTooLarge
212
+ : // More files at once than there was room for: the surplus was dropped, the
213
+ // rest kept. The count is how many fit, as keksdose says it.
214
+ toastText.attachmentTooMany(info.max ?? DEFAULT_MAX_ATTACHMENTS),
215
+ )
216
+ }
217
+ onCaptureScreenshot={onCaptureScreenshot}
218
+ onSubmit={submit}
219
+ contextSlot={<FeedbackContextBox user={user} url={url} attachUrl={attachUrl} onAttachUrlChange={setAttachUrl} />}
220
+ />
221
+ ) : null;
222
+
223
+ return { open, close, dialog };
224
+ }
@@ -0,0 +1,170 @@
1
+ import { Ban, Check, ChevronsRight } from "lucide-react";
2
+ import type { SwipeAction } from "../components/swipeable-row";
3
+ import type { MobileSwipeActions } from "../components/data-table";
4
+ import { nextFeedbackStatus, type FeedbackStatus } from "./feedback-inbox";
5
+ import type { FeedbackStatusLabels } from "./feedback-labels";
6
+ import type { FeedbackStatusChange, FeedbackStatusRow } from "./feedback-status-undo";
7
+
8
+ /**
9
+ * Swipes on the admin inbox's phone cards — §4.5 of the feedback contract, keksdose's
10
+ * (`feedback-page.tsx:990`, feedback #144 rework item 4: *"it is a queue, and triaging it
11
+ * one tap-into-the-row at a time is the slow way"*), for every app.
12
+ *
13
+ * - `advance` — one step along the chain (`nextFeedbackStatus`: OPEN → IN_PROGRESS →
14
+ * IN_EVALUATION → DONE), labelled with the status it goes to;
15
+ * - `done` — straight to DONE, for a row that plainly needs no stop on the way;
16
+ * - `wont_do` — dismiss as WONT_DO.
17
+ *
18
+ * The same transitions the status column offers, so nothing is reachable by gesture that
19
+ * is not reachable by tap. keksdose lets the user rebind them (Settings → Interaction,
20
+ * feedback #404) and keeps that storage; kastlan and Kurvenschmiede take the default.
21
+ */
22
+ export const FEEDBACK_SWIPE_ACTIONS = ["advance", "done", "wont_do"] as const;
23
+
24
+ /** One of {@link FEEDBACK_SWIPE_ACTIONS}. */
25
+ export type FeedbackSwipeAction = (typeof FEEDBACK_SWIPE_ACTIONS)[number];
26
+
27
+ /**
28
+ * Which swipe does what — the same shape as 0.26's `TranslationReviewSwipeBinding`: per
29
+ * LOGICAL side an ordered ladder, index 0 at the first threshold, index 1 at the longer
30
+ * drag. `end` is a drag toward the reading end (right in a left-to-right page), `start`
31
+ * the other way. keksdose stores PHYSICAL `right` / `left` ladders and hands
32
+ * `{ end: right, start: left }`, as its translations page does
33
+ * (`translations-page.tsx:280`).
34
+ *
35
+ * The ids are typed loosely on purpose: a stored binding carries keksdose's `"none"`
36
+ * fillers and may carry an id from another version or another surface — they are
37
+ * dropped (see {@link feedbackSwipePlan}), so it is passed in without a cast.
38
+ */
39
+ export interface FeedbackSwipeBinding {
40
+ /** A drag toward the reading START, nearest threshold first. */
41
+ start?: readonly (FeedbackSwipeAction | (string & {}))[];
42
+ /** A drag toward the reading END, nearest threshold first. */
43
+ end?: readonly (FeedbackSwipeAction | (string & {}))[];
44
+ }
45
+
46
+ /** keksdose's shipped binding (`swipe-prefs-store.ts:99`): toward the end advance, then
47
+ * done on the longer drag; toward the start won't do. A settings page's reset goes here. */
48
+ export const DEFAULT_FEEDBACK_SWIPE: FeedbackSwipeBinding = Object.freeze({
49
+ start: Object.freeze(["wont_do"] as const),
50
+ end: Object.freeze(["advance", "done"] as const),
51
+ });
52
+
53
+ export interface FeedbackSwipeOptions {
54
+ /** What a swipe commits through: the undoable change from `useFeedbackStatusUndo`, the
55
+ * same one the status cell and the detail's pills use — so a swipe gets the same 8 s
56
+ * "Set to …" toast with Undo as a tap (§4.5, §7.10, Marcel 2026-10-04; keksdose's
57
+ * `changeStatusUndoably` on its swipes too). A swipe is the easiest status change to
58
+ * make by accident. The plan calls it and nothing else, so the page owns the PATCH. */
59
+ change: FeedbackStatusChange;
60
+ /** The status names the panels show — `useFeedbackStatusLabels()`. The `done` panel
61
+ * reads DONE's, `wont_do` WONT_DO's, `advance` the next status's. */
62
+ labels: FeedbackStatusLabels;
63
+ }
64
+
65
+ /** Where an action takes a row, or null when it would do nothing from there. */
66
+ function targetOf(id: string, status: FeedbackStatus): FeedbackStatus | null {
67
+ switch (id) {
68
+ case "advance":
69
+ // Null at the chain's end (DONE) and off it (NEEDS_LIVE_TEST, POSTPONED, WONT_DO):
70
+ // "advance" has no meaning there, and a threshold that commits nothing is a trap.
71
+ return nextFeedbackStatus(status);
72
+ case "done":
73
+ return status === "DONE" ? null : "DONE";
74
+ case "wont_do":
75
+ return status === "WONT_DO" ? null : "WONT_DO";
76
+ default:
77
+ // "none", or an id this version does not know: an empty slot.
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** One action's panel. Each fill carries its text colour (0.26): the fills turn pastel
83
+ * in dark mode, where SwipeableRow's default white text was unreadable. `--info` has no
84
+ * contrast token, so `advance` writes in the surface colour — white on sky in light
85
+ * mode, the dark page on the pale sky in dark (the translation list's reset does the
86
+ * same on its grey). */
87
+ function panel(
88
+ id: FeedbackSwipeAction,
89
+ target: FeedbackStatus,
90
+ row: FeedbackStatusRow,
91
+ { change, labels }: FeedbackSwipeOptions,
92
+ ): SwipeAction {
93
+ const onCommit = () => change(row, target);
94
+ switch (id) {
95
+ case "advance":
96
+ return {
97
+ onCommit,
98
+ label: labels[target],
99
+ icon: <ChevronsRight className="size-4" aria-hidden />,
100
+ className: "bg-[var(--info)] text-[var(--info-contrast)]",
101
+ armedClassName: "bg-[var(--info)] text-[var(--info-contrast)]",
102
+ };
103
+ case "done":
104
+ return {
105
+ onCommit,
106
+ label: labels.DONE,
107
+ icon: <Check className="size-4" aria-hidden />,
108
+ className: "bg-[var(--success)] text-[var(--success-contrast)]",
109
+ armedClassName: "bg-[var(--success)] text-[var(--success-contrast)]",
110
+ };
111
+ case "wont_do":
112
+ return {
113
+ onCommit,
114
+ label: labels.WONT_DO,
115
+ icon: <Ban className="size-4" aria-hidden />,
116
+ className: "bg-[var(--danger)] text-[var(--danger-contrast)]",
117
+ armedClassName: "bg-[var(--danger-hover)] text-[var(--danger-contrast)]",
118
+ };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The binding resolved against one row — what DataTable's `mobileSwipeActions` returns
124
+ * for it, or `null` when the row offers no swipe at all:
125
+ *
126
+ * ```tsx
127
+ * const changeStatus = useFeedbackStatusUndo(update.mutate);
128
+ * const statusLabels = useFeedbackStatusLabels();
129
+ * <DataTable
130
+ * mobileSwipeActions={isAdmin && !mine
131
+ * ? (row) => feedbackSwipePlan(DEFAULT_FEEDBACK_SWIPE, row, { change: changeStatus, labels: statusLabels })
132
+ * : undefined}
133
+ * />
134
+ * ```
135
+ *
136
+ * Modelled on 0.26's `translationReviewSwipePlan` (keksdose's `resolveSwipePlan`): an
137
+ * action the row cannot take DROPS OUT and the ladder closes up behind it, so the second
138
+ * stage becomes the first instead of the side going dead behind an unreachable stage:
139
+ *
140
+ * - `advance` on DONE and off the chain (NEEDS_LIVE_TEST, POSTPONED, WONT_DO);
141
+ * - `done` on DONE, `wont_do` on WONT_DO;
142
+ * - `"none"` and any id this version does not know;
143
+ * - a later stage that lands where an earlier one on the same side already does — the id
144
+ * named twice, or `done` behind `advance` on IN_EVALUATION (both go to DONE): a longer
145
+ * drag to the same effect would arm a threshold that adds nothing.
146
+ *
147
+ * Only what the ROW decides. Whether the page may write at all — the admin inbox, not
148
+ * `/my-feedback`; no write lock — is the caller's, by passing no `mobileSwipeActions`.
149
+ */
150
+ export function feedbackSwipePlan(
151
+ binding: FeedbackSwipeBinding,
152
+ row: FeedbackStatusRow,
153
+ options: FeedbackSwipeOptions,
154
+ ): MobileSwipeActions | null {
155
+ const side = (ids: FeedbackSwipeBinding["start"]): SwipeAction[] => {
156
+ const reached = new Set<FeedbackStatus>();
157
+ const out: SwipeAction[] = [];
158
+ for (const id of ids ?? []) {
159
+ const target = targetOf(id, row.status);
160
+ if (target === null || reached.has(target)) continue;
161
+ reached.add(target);
162
+ out.push(panel(id as FeedbackSwipeAction, target, row, options));
163
+ }
164
+ return out;
165
+ };
166
+ const start = side(binding.start);
167
+ const end = side(binding.end);
168
+ if (start.length === 0 && end.length === 0) return null;
169
+ return { start, end };
170
+ }