@marktiderman/genesis-support 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +130 -2
- package/dist/block-fZjTS2sY.d.ts +39 -0
- package/dist/chunk-4VFYVQUF.js +278 -0
- package/dist/chunk-4VFYVQUF.js.map +1 -0
- package/dist/chunk-ZV4KJBUF.js +284 -0
- package/dist/chunk-ZV4KJBUF.js.map +1 -0
- package/dist/copy-CuTArBFg.d.ts +477 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +74 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +207 -0
- package/dist/native.js +722 -0
- package/dist/native.js.map +1 -0
- package/dist/web.d.ts +137 -0
- package/dist/web.js +484 -0
- package/dist/web.js.map +1 -0
- package/package.json +252 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Tiderman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @marktiderman/genesis-support
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Support and feedback for Genesis apps, kept in **the app's own Supabase**:
|
|
4
|
+
|
|
5
|
+
- a **feedback record** (bug, idea, question or praise; the message; the version signature,
|
|
6
|
+
route, platform and device; screenshots) behind two seams, a `ContextCollector` and a
|
|
7
|
+
`FeedbackSink`, with a Supabase sink;
|
|
8
|
+
- **web**: a feedback panel and a floating launcher built from Genesis UI, a screenshot of the tab
|
|
9
|
+
(the browser asks first) with pen marks and hidden areas, and pins on the page;
|
|
10
|
+
- **native**: a feedback screen, screenshot capture with mark-up that can hide private parts, and
|
|
11
|
+
**shake to report**; the person's reports as an inbox with staff replies and read receipts; an
|
|
12
|
+
**idea board** with one vote per person, and "you asked, we shipped" tied to FeatureIds;
|
|
13
|
+
- a `defineBlock` declaration (`support-feedback`) that adds **Send feedback** and **Help** to
|
|
14
|
+
Settings, names its routes and its migration.
|
|
15
|
+
|
|
16
|
+
The database half is migration `0006_genesis_support.sql` in `@marktiderman/genesis-supabase`,
|
|
17
|
+
with its pgTAP test and three Edge Function templates (see that package's README).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add @marktiderman/genesis-support @marktiderman/genesis-supabase
|
|
23
|
+
# native, for screenshots and shake to report (both optional):
|
|
24
|
+
npx expo install react-native-view-shot expo-sensors
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Copy `migrations/0006_genesis_support.sql` (and `migrations/tests/0006_genesis_support.test.sql`)
|
|
28
|
+
from `@marktiderman/genesis-supabase` into your app's `supabase/` under a new timestamped name.
|
|
29
|
+
|
|
30
|
+
## Native
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// src/lib/support.ts
|
|
34
|
+
import * as ViewShot from "react-native-view-shot";
|
|
35
|
+
import { Accelerometer } from "expo-sensors";
|
|
36
|
+
import { router, usePathname } from "expo-router";
|
|
37
|
+
import { createSupport } from "@marktiderman/genesis-support/native";
|
|
38
|
+
|
|
39
|
+
export const support = createSupport({
|
|
40
|
+
supabase,
|
|
41
|
+
navigate: (href) => router.push(href),
|
|
42
|
+
appVersion: () => versionSignature({ application, updates, platform }).short,
|
|
43
|
+
useCurrentRoute: usePathname,
|
|
44
|
+
viewShot: ViewShot, // screenshots and mark-up
|
|
45
|
+
accelerometer: Accelerometer, // shake to report
|
|
46
|
+
basePath: "/settings/support",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// app.ts
|
|
50
|
+
<GenesisNativeApp blocks={[support]} appFeatures={["account"]} … />
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Settings gets the two rows through `useBlockSettings` (section `support`). The screens are your
|
|
54
|
+
route files, each a genesis-ui-native layout fed by a hook:
|
|
55
|
+
|
|
56
|
+
| Route (under `basePath`) | File | Renders |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `/` | `support/index.tsx` | `<InboxPage {...useSupportInbox()} />` |
|
|
59
|
+
| `/new` | `support/new.tsx` | `<SettingsSubPage><FeedbackForm /></SettingsSubPage>` |
|
|
60
|
+
| `/[id]` | `support/[id].tsx` | `<ThreadPage {...useSupportThread(id)} />` |
|
|
61
|
+
| `/ideas` | `support/ideas.tsx` | `<InboxPage {...useIdeaBoard()} />` |
|
|
62
|
+
| `/shipped` | `support/shipped.tsx` | `<InboxPage {...useShippedIdeas()} />` |
|
|
63
|
+
|
|
64
|
+
The hooks return the layout's props plus `headerTitle` for `<Stack.Screen>`. Data is cached with
|
|
65
|
+
TanStack Query under the `genesis-support` key root; leave it out of query persistence.
|
|
66
|
+
|
|
67
|
+
**Shake to report.** With an accelerometer, a shake (two strong jolts within a second, then a
|
|
68
|
+
three second rest) captures the screen and opens the feedback screen with it. The screenshot is
|
|
69
|
+
sent only if the person turns on **Include this screenshot** (off by default); **Mark up** lets them draw on it and **Hide** paints solid boxes
|
|
70
|
+
over anything private. The marks are flattened into the image (`captureRef`) before it is sent; if
|
|
71
|
+
flattening fails, the unmarked image is never sent in its place, and leaving the mark-up
|
|
72
|
+
without saving turns Include off again. On the web, marks stay a list until Send and are drawn
|
|
73
|
+
into the image once.
|
|
74
|
+
|
|
75
|
+
**From anywhere** (a devtools overlay's "Send feedback" slot, a help icon):
|
|
76
|
+
`<SendFeedbackButton />`, or `useSupport().openFeedback()`.
|
|
77
|
+
|
|
78
|
+
## Web
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { createSupportClient } from "@marktiderman/genesis-support";
|
|
82
|
+
import { FeedbackLauncher, mountSupportWidget } from "@marktiderman/genesis-support/web";
|
|
83
|
+
|
|
84
|
+
const client = createSupportClient(supabase);
|
|
85
|
+
<FeedbackLauncher client={client} />; // in a React app
|
|
86
|
+
const unmount = mountSupportWidget({ client }); // on any page (needs Genesis UI's styles)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`FeedbackPanel` is the panel on its own. A screenshot uses `getDisplayMedia` with
|
|
90
|
+
`preferCurrentTab`: the browser asks, and when the person says no there is no screenshot (never a
|
|
91
|
+
placeholder). Routes are sent as paths only: query strings and fragments are dropped on the client,
|
|
92
|
+
in the contract's validation and by a database check, and path segments that look private (an
|
|
93
|
+
email, an id, a long token) become `:email`, `:id` or `:token`.
|
|
94
|
+
|
|
95
|
+
## Core
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { createSupportClient, createContextCollector, createIntakeClient, createIntakeSink } from "@marktiderman/genesis-support";
|
|
99
|
+
|
|
100
|
+
const client = createSupportClient(supabase);
|
|
101
|
+
await client.submit({ type: "bug", message, context: await collector.collect(), attachments: [{ kind: "screenshot", contentType: "image/jpeg", base64 }] });
|
|
102
|
+
// → { id, attachmentsDropped }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `createSupportClient(supabase)`: `submit` (the Supabase sink), `myReports`, `report`,
|
|
106
|
+
`replies`, `reply`, `markRead`, `deleteReport`, `attachments` (signed URLs), `ideas`,
|
|
107
|
+
`suggestIdea`, `vote`, `unvote`, `shippedForMe`. Errors are `SupportError` with the database's
|
|
108
|
+
code (`rateLimited` for `GS429`).
|
|
109
|
+
- `createIntakeClient` + `createIntakeSink`: the wire contract over HTTP, for code without
|
|
110
|
+
supabase-js (it takes no screenshots, and says so in `attachmentsDropped`).
|
|
111
|
+
- `contract.ts` is the wire contract (`CONTRACT_VERSION`, headers, `SubmitFeedbackInput`,
|
|
112
|
+
`validateSubmitInput`), byte-identical to the intake function's copy (a test checks).
|
|
113
|
+
- `resolveSupportCopy(overrides)`: every string, in English, overridable key by key.
|
|
114
|
+
|
|
115
|
+
A GitHub issue per report, or a signed webhook, is a server-side sink: the
|
|
116
|
+
`genesis-support-notify` function template in `@marktiderman/genesis-supabase`.
|
|
117
|
+
|
|
118
|
+
## Access, in the database
|
|
119
|
+
|
|
120
|
+
A person sees, edits (while open) and deletes their own reports, replies on them and sees their
|
|
121
|
+
own screenshots. Every signed-in person reads the idea board (not who suggested an idea); one vote per person and idea, and a
|
|
122
|
+
Supabase anonymous sign-in can neither vote nor suggest. Staff are whoever your
|
|
123
|
+
`private.genesis_support_staff(person uuid)` hook says (none until you add it). Status, owner,
|
|
124
|
+
feature id, vote counts and read receipts are the server's. See the migration's header.
|
|
125
|
+
|
|
126
|
+
## Not yet
|
|
127
|
+
|
|
128
|
+
- No staff (operator) screens: staff work through the database functions or a tool of yours.
|
|
129
|
+
- No live updates (Realtime): the thread re-reads every 30 seconds while open.
|
|
130
|
+
- No ratings, no web idea board, no notification when an idea ships.
|
|
131
|
+
- The CDN (script-tag) build of the web loader isn't published; `mountSupportWidget` is the loader.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as _marktiderman_genesis_features_blocks from '@marktiderman/genesis-features/blocks';
|
|
2
|
+
import { BlockContributions } from '@marktiderman/genesis-features/blocks';
|
|
3
|
+
import { A as SupportCopyOverrides } from './copy-CuTArBFg.js';
|
|
4
|
+
|
|
5
|
+
/** The FeatureId of the support block (features.ts). */
|
|
6
|
+
declare const SUPPORT_BLOCK_ID = "support-feedback";
|
|
7
|
+
/** Where the block's screens live, under `basePath`. */
|
|
8
|
+
interface SupportPaths {
|
|
9
|
+
/** Help: the person's reports and links to the idea board. */
|
|
10
|
+
inbox: string;
|
|
11
|
+
/** The feedback form. */
|
|
12
|
+
feedback: string;
|
|
13
|
+
/** One report's thread: `thread(id)`. */
|
|
14
|
+
thread: (id: string) => string;
|
|
15
|
+
ideas: string;
|
|
16
|
+
shipped: string;
|
|
17
|
+
}
|
|
18
|
+
declare function supportPaths(basePath?: string): SupportPaths;
|
|
19
|
+
interface SupportBlockOptions {
|
|
20
|
+
/** Default `/support`. The app owns the route files under it. */
|
|
21
|
+
basePath?: string;
|
|
22
|
+
copy?: SupportCopyOverrides;
|
|
23
|
+
/** The Settings rows' section (default `support`), or `false` for none. */
|
|
24
|
+
settings?: false | {
|
|
25
|
+
section?: string;
|
|
26
|
+
};
|
|
27
|
+
/** A subtitle hook for the Help row (an unread count). Called on every Settings render. */
|
|
28
|
+
useHelpSubtitle?: () => string | null | undefined;
|
|
29
|
+
/** `false` leaves the idea board out. Default on. */
|
|
30
|
+
ideas?: boolean;
|
|
31
|
+
/** The block's other FeatureIds it needs (default `["account"]`: a signed-in person). */
|
|
32
|
+
requires?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/** The block's contributions, for any platform. */
|
|
35
|
+
declare function supportContributions(options?: SupportBlockOptions): BlockContributions<never>;
|
|
36
|
+
/** The platform-neutral declaration. */
|
|
37
|
+
declare function defineSupportBlock(options?: SupportBlockOptions): _marktiderman_genesis_features_blocks.BlockDef<"support-feedback", never>;
|
|
38
|
+
|
|
39
|
+
export { SUPPORT_BLOCK_ID as S, type SupportBlockOptions as a, type SupportPaths as b, supportPaths as c, defineSupportBlock as d, supportContributions as s };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { resolveSupportCopy, sanitizeContext, attachmentBytes } from './chunk-ZV4KJBUF.js';
|
|
2
|
+
import { defineBlock } from '@marktiderman/genesis-features/blocks';
|
|
3
|
+
|
|
4
|
+
// src/client.ts
|
|
5
|
+
var SUPPORT_BUCKET = "genesis-support";
|
|
6
|
+
var SupportError = class extends Error {
|
|
7
|
+
constructor(message, code, operation) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.operation = operation;
|
|
11
|
+
this.name = "SupportError";
|
|
12
|
+
}
|
|
13
|
+
code;
|
|
14
|
+
operation;
|
|
15
|
+
/** The database's rate limit (SQLSTATE GS429). */
|
|
16
|
+
get rateLimited() {
|
|
17
|
+
return this.code === "GS429";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function must(operation, result) {
|
|
21
|
+
if (result.error) throw new SupportError(result.error.message, result.error.code, operation);
|
|
22
|
+
return result.data;
|
|
23
|
+
}
|
|
24
|
+
var REPORT_COLUMNS = "id, type, title, message, status, feature_id, route, app_version, created_at, updated_at, last_staff_reply_at, last_user_reply_at, user_read_at, staff_read_at";
|
|
25
|
+
var IDEA_COLUMNS = "id, title, body, status, feature_id, shipped_at, vote_count, created_at";
|
|
26
|
+
function isUnread(row) {
|
|
27
|
+
if (!row.lastStaffReplyAt) return false;
|
|
28
|
+
return !row.userReadAt || new Date(row.lastStaffReplyAt).getTime() > new Date(row.userReadAt).getTime();
|
|
29
|
+
}
|
|
30
|
+
function toReport(r) {
|
|
31
|
+
const report = {
|
|
32
|
+
id: String(r.id),
|
|
33
|
+
type: r.type,
|
|
34
|
+
title: r.title ?? null,
|
|
35
|
+
message: String(r.message ?? ""),
|
|
36
|
+
status: r.status,
|
|
37
|
+
featureId: r.feature_id ?? null,
|
|
38
|
+
route: r.route ?? null,
|
|
39
|
+
appVersion: r.app_version ?? null,
|
|
40
|
+
createdAt: String(r.created_at),
|
|
41
|
+
updatedAt: String(r.updated_at),
|
|
42
|
+
lastStaffReplyAt: r.last_staff_reply_at ?? null,
|
|
43
|
+
lastUserReplyAt: r.last_user_reply_at ?? null,
|
|
44
|
+
userReadAt: r.user_read_at ?? null,
|
|
45
|
+
staffReadAt: r.staff_read_at ?? null
|
|
46
|
+
};
|
|
47
|
+
return { ...report, unread: isUnread(report) };
|
|
48
|
+
}
|
|
49
|
+
function toIdea(r, mine, voted) {
|
|
50
|
+
return {
|
|
51
|
+
id: String(r.id),
|
|
52
|
+
title: String(r.title ?? ""),
|
|
53
|
+
body: r.body ?? null,
|
|
54
|
+
status: r.status,
|
|
55
|
+
featureId: r.feature_id ?? null,
|
|
56
|
+
shippedAt: r.shipped_at ?? null,
|
|
57
|
+
voteCount: Number(r.vote_count ?? 0),
|
|
58
|
+
voted: voted.has(String(r.id)),
|
|
59
|
+
mine: mine.has(String(r.id)),
|
|
60
|
+
createdAt: String(r.created_at)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
var EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
|
|
64
|
+
function defaultRandomId() {
|
|
65
|
+
const c = globalThis.crypto;
|
|
66
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
67
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
68
|
+
}
|
|
69
|
+
function createSupportClient(supabase, options = {}) {
|
|
70
|
+
const bucket = options.bucket ?? SUPPORT_BUCKET;
|
|
71
|
+
const signedUrlSeconds = options.signedUrlSeconds ?? 3600;
|
|
72
|
+
const randomId = options.randomId ?? defaultRandomId;
|
|
73
|
+
const me = async () => {
|
|
74
|
+
const { data } = await supabase.auth.getSession();
|
|
75
|
+
return data.session?.user?.id ?? null;
|
|
76
|
+
};
|
|
77
|
+
const client = {
|
|
78
|
+
async submit(record) {
|
|
79
|
+
const userId = await me();
|
|
80
|
+
if (!userId) throw new SupportError("Sign in to send feedback.", "42501", "submit");
|
|
81
|
+
const context = sanitizeContext(record.context);
|
|
82
|
+
const row = must(
|
|
83
|
+
"submit",
|
|
84
|
+
await supabase.from("support_reports").insert({
|
|
85
|
+
type: record.type,
|
|
86
|
+
title: record.title?.trim() ? record.title.trim().slice(0, 300) : null,
|
|
87
|
+
message: record.message,
|
|
88
|
+
route: context.route ?? null,
|
|
89
|
+
app_version: context.appVersion ?? null,
|
|
90
|
+
platform: context.platform ?? null,
|
|
91
|
+
device: context.device ?? {},
|
|
92
|
+
context: context.extra ?? {}
|
|
93
|
+
}).select("id").single()
|
|
94
|
+
);
|
|
95
|
+
const id = String(row.id);
|
|
96
|
+
let dropped = 0;
|
|
97
|
+
for (const attachment of (record.attachments ?? []).slice(0, 5)) {
|
|
98
|
+
try {
|
|
99
|
+
const bytes = attachmentBytes(attachment);
|
|
100
|
+
if (!bytes) throw new Error("no image data");
|
|
101
|
+
const path = `${userId}/${id}/${randomId().replace(/[^A-Za-z0-9_-]/g, "")}.${EXTENSIONS[attachment.contentType] ?? "png"}`;
|
|
102
|
+
const upload = await supabase.storage.from(bucket).upload(path, bytes, { contentType: attachment.contentType, upsert: false });
|
|
103
|
+
if (upload.error) throw upload.error;
|
|
104
|
+
must(
|
|
105
|
+
"attach",
|
|
106
|
+
await supabase.from("support_attachments").insert({
|
|
107
|
+
report_id: id,
|
|
108
|
+
path,
|
|
109
|
+
content_type: attachment.contentType,
|
|
110
|
+
byte_size: bytes.byteLength,
|
|
111
|
+
width: attachment.width ? Math.round(attachment.width) : null,
|
|
112
|
+
height: attachment.height ? Math.round(attachment.height) : null
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
dropped += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
dropped += Math.max(0, (record.attachments?.length ?? 0) - 5);
|
|
120
|
+
return { id, attachmentsDropped: dropped };
|
|
121
|
+
},
|
|
122
|
+
async myReports({ limit = 50 } = {}) {
|
|
123
|
+
const userId = await me();
|
|
124
|
+
if (!userId) return [];
|
|
125
|
+
const rows = must(
|
|
126
|
+
"myReports",
|
|
127
|
+
await supabase.from("support_reports").select(REPORT_COLUMNS).eq("user_id", userId).order("created_at", { ascending: false }).limit(limit)
|
|
128
|
+
);
|
|
129
|
+
return rows.map(toReport);
|
|
130
|
+
},
|
|
131
|
+
async report(id) {
|
|
132
|
+
const row = must("report", await supabase.from("support_reports").select(REPORT_COLUMNS).eq("id", id).maybeSingle());
|
|
133
|
+
return row ? toReport(row) : null;
|
|
134
|
+
},
|
|
135
|
+
async replies(reportId) {
|
|
136
|
+
const rows = must(
|
|
137
|
+
"replies",
|
|
138
|
+
await supabase.from("support_replies").select("id, report_id, body, from_staff, created_at").eq("report_id", reportId).order("created_at", { ascending: true })
|
|
139
|
+
);
|
|
140
|
+
return rows.map((r) => ({
|
|
141
|
+
id: String(r.id),
|
|
142
|
+
reportId: String(r.report_id),
|
|
143
|
+
body: String(r.body ?? ""),
|
|
144
|
+
fromStaff: r.from_staff === true,
|
|
145
|
+
mine: r.from_staff !== true,
|
|
146
|
+
createdAt: String(r.created_at)
|
|
147
|
+
}));
|
|
148
|
+
},
|
|
149
|
+
async reply(reportId, body) {
|
|
150
|
+
const row = must(
|
|
151
|
+
"reply",
|
|
152
|
+
await supabase.from("support_replies").insert({ report_id: reportId, body }).select("id, report_id, body, from_staff, created_at").single()
|
|
153
|
+
);
|
|
154
|
+
return { id: String(row.id), reportId: String(row.report_id), body: String(row.body), fromStaff: row.from_staff === true, mine: true, createdAt: String(row.created_at) };
|
|
155
|
+
},
|
|
156
|
+
async markRead(reportId) {
|
|
157
|
+
must("markRead", await supabase.rpc("support_mark_read", { report: reportId }));
|
|
158
|
+
},
|
|
159
|
+
async deleteReport(reportId) {
|
|
160
|
+
const rows = must("deleteReport", await supabase.from("support_reports").delete().eq("id", reportId).select("id"));
|
|
161
|
+
if (!rows.length) throw new SupportError("That report wasn't deleted.", "42501", "deleteReport");
|
|
162
|
+
},
|
|
163
|
+
async attachments(reportId) {
|
|
164
|
+
const rows = must(
|
|
165
|
+
"attachments",
|
|
166
|
+
await supabase.from("support_attachments").select("id, path, content_type, width, height").eq("report_id", reportId).order("created_at")
|
|
167
|
+
);
|
|
168
|
+
if (!rows.length) return [];
|
|
169
|
+
const signed = await supabase.storage.from(bucket).createSignedUrls(
|
|
170
|
+
rows.map((r) => String(r.path)),
|
|
171
|
+
signedUrlSeconds
|
|
172
|
+
);
|
|
173
|
+
const urls = new Map((signed.data ?? []).map((s) => [s.path, s.signedUrl]));
|
|
174
|
+
return rows.map((r) => ({
|
|
175
|
+
id: String(r.id),
|
|
176
|
+
path: String(r.path),
|
|
177
|
+
contentType: String(r.content_type),
|
|
178
|
+
width: r.width ?? null,
|
|
179
|
+
height: r.height ?? null,
|
|
180
|
+
url: urls.get(String(r.path)) ?? null
|
|
181
|
+
}));
|
|
182
|
+
},
|
|
183
|
+
async ideas({ limit = 100, statuses } = {}) {
|
|
184
|
+
const userId = await me();
|
|
185
|
+
let query = supabase.from("support_ideas").select(IDEA_COLUMNS).neq("status", "hidden");
|
|
186
|
+
if (statuses?.length) query = query.in("status", statuses);
|
|
187
|
+
const rows = must("ideas", await query.order("vote_count", { ascending: false }).order("created_at", { ascending: false }).limit(limit));
|
|
188
|
+
const votes = userId ? must("ideas", await supabase.from("support_idea_votes").select("idea_id").eq("user_id", userId)) : [];
|
|
189
|
+
const voted = new Set(votes.map((v) => String(v.idea_id)));
|
|
190
|
+
const mineIds = userId ? must("ideas", await supabase.rpc("support_my_idea_ids")) : [];
|
|
191
|
+
const mine = new Set((mineIds ?? []).map((v) => String(typeof v === "object" && v !== null ? Object.values(v)[0] : v)));
|
|
192
|
+
return rows.map((r) => toIdea(r, mine, voted));
|
|
193
|
+
},
|
|
194
|
+
async suggestIdea({ title, body }) {
|
|
195
|
+
const row = must("suggestIdea", await supabase.from("support_ideas").insert({ title: title.trim(), body: body?.trim() || null }).select(IDEA_COLUMNS).single());
|
|
196
|
+
return toIdea(row, /* @__PURE__ */ new Set([String(row.id)]), /* @__PURE__ */ new Set());
|
|
197
|
+
},
|
|
198
|
+
async vote(ideaId) {
|
|
199
|
+
const result = await supabase.from("support_idea_votes").insert({ idea_id: ideaId });
|
|
200
|
+
if (result.error && result.error.code !== "23505") must("vote", result);
|
|
201
|
+
},
|
|
202
|
+
async unvote(ideaId) {
|
|
203
|
+
const userId = await me();
|
|
204
|
+
if (!userId) return;
|
|
205
|
+
must("unvote", await supabase.from("support_idea_votes").delete().eq("idea_id", ideaId).eq("user_id", userId));
|
|
206
|
+
},
|
|
207
|
+
async shippedForMe() {
|
|
208
|
+
const rows = must("shippedForMe", await supabase.rpc("support_shipped_for_me", { max_rows: 50 }));
|
|
209
|
+
const ids = (pick) => new Set(rows.filter((r) => r[pick] === true).map((r) => String(r.id)));
|
|
210
|
+
return rows.map((r) => toIdea(r, ids("mine"), ids("voted")));
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
return client;
|
|
214
|
+
}
|
|
215
|
+
function createSupabaseSink(supabase, options = {}) {
|
|
216
|
+
const client = createSupportClient(supabase, options);
|
|
217
|
+
return { submit: (record) => client.submit(record) };
|
|
218
|
+
}
|
|
219
|
+
var SUPPORT_BLOCK_ID = "support-feedback";
|
|
220
|
+
function supportPaths(basePath = "/support") {
|
|
221
|
+
const base = basePath.replace(/\/$/, "") || "/support";
|
|
222
|
+
return {
|
|
223
|
+
inbox: base,
|
|
224
|
+
feedback: `${base}/new`,
|
|
225
|
+
thread: (id) => `${base}/${encodeURIComponent(id)}`,
|
|
226
|
+
ideas: `${base}/ideas`,
|
|
227
|
+
shipped: `${base}/shipped`
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
var PACKAGE = "@marktiderman/genesis-supabase";
|
|
231
|
+
function supportContributions(options = {}) {
|
|
232
|
+
const copy = resolveSupportCopy(options.copy);
|
|
233
|
+
const paths = supportPaths(options.basePath);
|
|
234
|
+
const section = options.settings === false ? null : options.settings?.section ?? "support";
|
|
235
|
+
const settings = section ? [
|
|
236
|
+
{ id: "support-send-feedback", section, title: copy.settings.sendFeedback, href: paths.feedback, order: 10, testID: "genesis-support-send-feedback" },
|
|
237
|
+
{ id: "support-help", section, title: copy.settings.help, href: paths.inbox, order: 20, testID: "genesis-support-help", useSubtitle: options.useHelpSubtitle }
|
|
238
|
+
] : [];
|
|
239
|
+
const routes = [
|
|
240
|
+
{ path: paths.inbox, description: "the person's reports and staff replies", signedIn: true },
|
|
241
|
+
{ path: paths.feedback, description: "send feedback, with a screenshot", signedIn: true },
|
|
242
|
+
{ path: `${paths.inbox}/[id]`, description: "one report's conversation", signedIn: true },
|
|
243
|
+
...options.ideas === false ? [] : [
|
|
244
|
+
{ path: paths.ideas, description: "the idea board, with votes", signedIn: true },
|
|
245
|
+
{ path: paths.shipped, description: "ideas the person asked for that shipped", signedIn: true }
|
|
246
|
+
]
|
|
247
|
+
];
|
|
248
|
+
return {
|
|
249
|
+
settings,
|
|
250
|
+
routes,
|
|
251
|
+
migrations: [{ package: PACKAGE, file: "migrations/0006_genesis_support.sql", test: "migrations/tests/0006_genesis_support.test.sql" }],
|
|
252
|
+
privacy: {
|
|
253
|
+
collects: [
|
|
254
|
+
{ type: "user ID", purposes: ["app functionality"], linked: true, tracking: false },
|
|
255
|
+
{ type: "customer support", purposes: ["app functionality"], linked: true, tracking: false },
|
|
256
|
+
{ type: "other user content", purposes: ["app functionality"], linked: true, tracking: false },
|
|
257
|
+
{ type: "photos or videos", purposes: ["app functionality"], linked: true, tracking: false },
|
|
258
|
+
{ type: "other diagnostic data", purposes: ["app functionality"], linked: true, tracking: false }
|
|
259
|
+
],
|
|
260
|
+
notes: [
|
|
261
|
+
"Feedback is sent only when the person taps Send, and a screenshot only when they include it, after they can hide parts of it.",
|
|
262
|
+
"Reports, screenshots and replies are visible to the person and the app's support staff only, and go when the person deletes them or their account.",
|
|
263
|
+
"Feedback includes the app version, the screen's path (never its query string; private-looking segments are replaced), the platform and basic device facts."
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function defineSupportBlock(options = {}) {
|
|
269
|
+
return defineBlock({
|
|
270
|
+
id: SUPPORT_BLOCK_ID,
|
|
271
|
+
requires: options.requires ?? ["account"],
|
|
272
|
+
contributes: supportContributions(options)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export { SUPPORT_BLOCK_ID, SUPPORT_BUCKET, SupportError, createSupabaseSink, createSupportClient, defineSupportBlock, isUnread, supportContributions, supportPaths };
|
|
277
|
+
//# sourceMappingURL=chunk-4VFYVQUF.js.map
|
|
278
|
+
//# sourceMappingURL=chunk-4VFYVQUF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts","../src/block.ts"],"names":[],"mappings":"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","file":"chunk-4VFYVQUF.js","sourcesContent":["/**\n * The support client: the app's own Supabase (migration 0006 from\n * `@marktiderman/genesis-supabase`) under row level security. Everything a\n * person does (send feedback, read their reports and the replies, reply, vote\n * on the idea board) and the Supabase sink for the feedback record.\n */\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\nimport type { FeedbackStatus } from \"./contract\";\nimport { attachmentBytes, sanitizeContext, type FeedbackDelivery, type FeedbackRecord, type FeedbackSink, type FeedbackType } from \"./record\";\n\n/** Any supabase-js client; the app's generated Database types don't need to list the support tables. */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type SupportSupabase = SupabaseClient<any, any, any>;\n\n/** The storage bucket migration 0006 creates. */\nexport const SUPPORT_BUCKET = \"genesis-support\";\n\n/** A Supabase refusal, with its code (`GS429` rate limited, `42501` not allowed, …). */\nexport class SupportError extends Error {\n constructor(\n message: string,\n readonly code: string | undefined,\n readonly operation: string,\n ) {\n super(message);\n this.name = \"SupportError\";\n }\n /** The database's rate limit (SQLSTATE GS429). */\n get rateLimited(): boolean {\n return this.code === \"GS429\";\n }\n}\n\nfunction must<T>(operation: string, result: { data: T | null; error: { message: string; code?: string } | null }): T {\n if (result.error) throw new SupportError(result.error.message, result.error.code, operation);\n return result.data as T;\n}\n\nexport type IdeaStatus = \"open\" | \"planned\" | \"in_progress\" | \"shipped\" | \"declined\" | \"hidden\";\n\nexport interface SupportReport {\n id: string;\n type: FeedbackType;\n title: string | null;\n message: string;\n status: FeedbackStatus;\n /** The FeatureId staff linked it to. */\n featureId: string | null;\n route: string | null;\n appVersion: string | null;\n createdAt: string;\n updatedAt: string;\n lastStaffReplyAt: string | null;\n lastUserReplyAt: string | null;\n userReadAt: string | null;\n staffReadAt: string | null;\n /** A staff reply the person hasn't read. */\n unread: boolean;\n}\n\nexport interface SupportReply {\n id: string;\n reportId: string;\n body: string;\n fromStaff: boolean;\n /** Written by the signed-in person. */\n mine: boolean;\n createdAt: string;\n}\n\nexport interface SupportAttachmentView {\n id: string;\n path: string;\n contentType: string;\n width: number | null;\n height: number | null;\n /** A signed URL, valid for `signedUrlSeconds` (default an hour). Null when signing failed. */\n url: string | null;\n}\n\nexport interface SupportIdea {\n id: string;\n title: string;\n body: string | null;\n status: IdeaStatus;\n /** The FeatureId that delivers it (set when it ships). */\n featureId: string | null;\n shippedAt: string | null;\n voteCount: number;\n /** The signed-in person voted for it. */\n voted: boolean;\n /** The signed-in person suggested it. */\n mine: boolean;\n createdAt: string;\n}\n\nexport interface SupportClient extends FeedbackSink {\n /** The signed-in person's reports, newest first. */\n myReports(options?: { limit?: number }): Promise<SupportReport[]>;\n report(id: string): Promise<SupportReport | null>;\n replies(reportId: string): Promise<SupportReply[]>;\n reply(reportId: string, body: string): Promise<SupportReply>;\n markRead(reportId: string): Promise<void>;\n /** Deletes the person's own report; its screenshots are queued for removal. */\n deleteReport(reportId: string): Promise<void>;\n attachments(reportId: string): Promise<SupportAttachmentView[]>;\n /** The board, most votes first. Hidden ideas are left out, the person's own included. */\n ideas(options?: { limit?: number; statuses?: IdeaStatus[] }): Promise<SupportIdea[]>;\n suggestIdea(input: { title: string; body?: string }): Promise<SupportIdea>;\n vote(ideaId: string): Promise<void>;\n unvote(ideaId: string): Promise<void>;\n /** \"You asked, we shipped\": shipped ideas the person suggested or voted for. */\n shippedForMe(): Promise<SupportIdea[]>;\n}\n\nexport interface SupportClientOptions {\n bucket?: string;\n signedUrlSeconds?: number;\n /** For the attachment file names. Default `crypto.randomUUID`. */\n randomId?: () => string;\n}\n\nconst REPORT_COLUMNS =\n \"id, type, title, message, status, feature_id, route, app_version, created_at, updated_at, last_staff_reply_at, last_user_reply_at, user_read_at, staff_read_at\";\nconst IDEA_COLUMNS = \"id, title, body, status, feature_id, shipped_at, vote_count, created_at\";\n\ntype Row = Record<string, unknown>;\n\n/** True when there is a staff reply newer than the person's last read. */\nexport function isUnread(row: { lastStaffReplyAt: string | null; userReadAt: string | null }): boolean {\n if (!row.lastStaffReplyAt) return false;\n return !row.userReadAt || new Date(row.lastStaffReplyAt).getTime() > new Date(row.userReadAt).getTime();\n}\n\nfunction toReport(r: Row): SupportReport {\n const report: Omit<SupportReport, \"unread\"> = {\n id: String(r.id),\n type: r.type as FeedbackType,\n title: (r.title as string | null) ?? null,\n message: String(r.message ?? \"\"),\n status: r.status as FeedbackStatus,\n featureId: (r.feature_id as string | null) ?? null,\n route: (r.route as string | null) ?? null,\n appVersion: (r.app_version as string | null) ?? null,\n createdAt: String(r.created_at),\n updatedAt: String(r.updated_at),\n lastStaffReplyAt: (r.last_staff_reply_at as string | null) ?? null,\n lastUserReplyAt: (r.last_user_reply_at as string | null) ?? null,\n userReadAt: (r.user_read_at as string | null) ?? null,\n staffReadAt: (r.staff_read_at as string | null) ?? null,\n };\n return { ...report, unread: isUnread(report) };\n}\n\nfunction toIdea(r: Row, mine: ReadonlySet<string>, voted: ReadonlySet<string>): SupportIdea {\n return {\n id: String(r.id),\n title: String(r.title ?? \"\"),\n body: (r.body as string | null) ?? null,\n status: r.status as IdeaStatus,\n featureId: (r.feature_id as string | null) ?? null,\n shippedAt: (r.shipped_at as string | null) ?? null,\n voteCount: Number(r.vote_count ?? 0),\n voted: voted.has(String(r.id)),\n mine: mine.has(String(r.id)),\n createdAt: String(r.created_at),\n };\n}\n\nconst EXTENSIONS: Record<string, string> = { \"image/png\": \"png\", \"image/jpeg\": \"jpg\", \"image/webp\": \"webp\" };\n\nfunction defaultRandomId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) return c.randomUUID();\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/** The support client over the app's Supabase client. */\nexport function createSupportClient(supabase: SupportSupabase, options: SupportClientOptions = {}): SupportClient {\n const bucket = options.bucket ?? SUPPORT_BUCKET;\n const signedUrlSeconds = options.signedUrlSeconds ?? 3600;\n const randomId = options.randomId ?? defaultRandomId;\n\n const me = async (): Promise<string | null> => {\n const { data } = await supabase.auth.getSession();\n return data.session?.user?.id ?? null;\n };\n\n const client: SupportClient = {\n async submit(record: FeedbackRecord): Promise<FeedbackDelivery> {\n const userId = await me();\n if (!userId) throw new SupportError(\"Sign in to send feedback.\", \"42501\", \"submit\");\n const context = sanitizeContext(record.context);\n const row = must<Row>(\n \"submit\",\n await supabase\n .from(\"support_reports\")\n .insert({\n type: record.type,\n title: record.title?.trim() ? record.title.trim().slice(0, 300) : null,\n message: record.message,\n route: context.route ?? null,\n app_version: context.appVersion ?? null,\n platform: context.platform ?? null,\n device: context.device ?? {},\n context: context.extra ?? {},\n })\n .select(\"id\")\n .single(),\n );\n const id = String(row.id);\n let dropped = 0;\n for (const attachment of (record.attachments ?? []).slice(0, 5)) {\n try {\n const bytes = attachmentBytes(attachment);\n if (!bytes) throw new Error(\"no image data\");\n const path = `${userId}/${id}/${randomId().replace(/[^A-Za-z0-9_-]/g, \"\")}.${EXTENSIONS[attachment.contentType] ?? \"png\"}`;\n const upload = await supabase.storage.from(bucket).upload(path, bytes, { contentType: attachment.contentType, upsert: false });\n if (upload.error) throw upload.error;\n must(\n \"attach\",\n await supabase.from(\"support_attachments\").insert({\n report_id: id,\n path,\n content_type: attachment.contentType,\n byte_size: bytes.byteLength,\n width: attachment.width ? Math.round(attachment.width) : null,\n height: attachment.height ? Math.round(attachment.height) : null,\n }),\n );\n } catch {\n dropped += 1;\n }\n }\n dropped += Math.max(0, (record.attachments?.length ?? 0) - 5);\n return { id, attachmentsDropped: dropped };\n },\n\n async myReports({ limit = 50 } = {}) {\n const userId = await me();\n if (!userId) return [];\n const rows = must<Row[]>(\n \"myReports\",\n await supabase.from(\"support_reports\").select(REPORT_COLUMNS).eq(\"user_id\", userId).order(\"created_at\", { ascending: false }).limit(limit),\n );\n return rows.map(toReport);\n },\n\n async report(id) {\n const row = must<Row | null>(\"report\", await supabase.from(\"support_reports\").select(REPORT_COLUMNS).eq(\"id\", id).maybeSingle());\n return row ? toReport(row) : null;\n },\n\n async replies(reportId) {\n // Who wrote a reply isn't readable (staff stay anonymous): on the person's own report, theirs are the non-staff ones.\n const rows = must<Row[]>(\n \"replies\",\n await supabase.from(\"support_replies\").select(\"id, report_id, body, from_staff, created_at\").eq(\"report_id\", reportId).order(\"created_at\", { ascending: true }),\n );\n return rows.map((r) => ({\n id: String(r.id),\n reportId: String(r.report_id),\n body: String(r.body ?? \"\"),\n fromStaff: r.from_staff === true,\n mine: r.from_staff !== true,\n createdAt: String(r.created_at),\n }));\n },\n\n async reply(reportId, body) {\n const row = must<Row>(\n \"reply\",\n await supabase.from(\"support_replies\").insert({ report_id: reportId, body }).select(\"id, report_id, body, from_staff, created_at\").single(),\n );\n return { id: String(row.id), reportId: String(row.report_id), body: String(row.body), fromStaff: row.from_staff === true, mine: true, createdAt: String(row.created_at) };\n },\n\n async markRead(reportId) {\n must(\"markRead\", await supabase.rpc(\"support_mark_read\", { report: reportId }));\n },\n\n async deleteReport(reportId) {\n const rows = must<Row[]>(\"deleteReport\", await supabase.from(\"support_reports\").delete().eq(\"id\", reportId).select(\"id\"));\n if (!rows.length) throw new SupportError(\"That report wasn't deleted.\", \"42501\", \"deleteReport\");\n },\n\n async attachments(reportId) {\n const rows = must<Row[]>(\n \"attachments\",\n await supabase.from(\"support_attachments\").select(\"id, path, content_type, width, height\").eq(\"report_id\", reportId).order(\"created_at\"),\n );\n if (!rows.length) return [];\n const signed = await supabase.storage.from(bucket).createSignedUrls(\n rows.map((r) => String(r.path)),\n signedUrlSeconds,\n );\n const urls = new Map((signed.data ?? []).map((s) => [s.path, s.signedUrl] as const));\n return rows.map((r) => ({\n id: String(r.id),\n path: String(r.path),\n contentType: String(r.content_type),\n width: (r.width as number | null) ?? null,\n height: (r.height as number | null) ?? null,\n url: urls.get(String(r.path)) ?? null,\n }));\n },\n\n async ideas({ limit = 100, statuses } = {}) {\n const userId = await me();\n let query = supabase.from(\"support_ideas\").select(IDEA_COLUMNS).neq(\"status\", \"hidden\");\n if (statuses?.length) query = query.in(\"status\", statuses);\n const rows = must<Row[]>(\"ideas\", await query.order(\"vote_count\", { ascending: false }).order(\"created_at\", { ascending: false }).limit(limit));\n const votes = userId ? must<Row[]>(\"ideas\", await supabase.from(\"support_idea_votes\").select(\"idea_id\").eq(\"user_id\", userId)) : [];\n const voted = new Set(votes.map((v) => String(v.idea_id)));\n const mineIds = userId ? must<unknown[]>(\"ideas\", await supabase.rpc(\"support_my_idea_ids\")) : [];\n const mine = new Set((mineIds ?? []).map((v) => String(typeof v === \"object\" && v !== null ? Object.values(v)[0] : v)));\n return rows.map((r) => toIdea(r, mine, voted));\n },\n\n async suggestIdea({ title, body }) {\n const row = must<Row>(\"suggestIdea\", await supabase.from(\"support_ideas\").insert({ title: title.trim(), body: body?.trim() || null }).select(IDEA_COLUMNS).single());\n return toIdea(row, new Set([String(row.id)]), new Set());\n },\n\n async vote(ideaId) {\n const result = await supabase.from(\"support_idea_votes\").insert({ idea_id: ideaId });\n // Voting twice is already voting.\n if (result.error && result.error.code !== \"23505\") must(\"vote\", result);\n },\n\n async unvote(ideaId) {\n const userId = await me();\n if (!userId) return;\n must(\"unvote\", await supabase.from(\"support_idea_votes\").delete().eq(\"idea_id\", ideaId).eq(\"user_id\", userId));\n },\n\n async shippedForMe() {\n const rows = must<Row[]>(\"shippedForMe\", await supabase.rpc(\"support_shipped_for_me\", { max_rows: 50 }));\n const ids = (pick: string) => new Set(rows.filter((r) => r[pick] === true).map((r) => String(r.id)));\n return rows.map((r) => toIdea(r, ids(\"mine\"), ids(\"voted\")));\n },\n };\n return client;\n}\n\n/** The Supabase sink on its own (the support client is one). */\nexport function createSupabaseSink(supabase: SupportSupabase, options: SupportClientOptions = {}): FeedbackSink {\n const client = createSupportClient(supabase, options);\n return { submit: (record) => client.submit(record) };\n}\n","/**\n * The support block as a `defineBlock` declaration: what it adds to an app\n * (two Settings rows, its routes, migration 0006) and what it means for the\n * stores' privacy forms. Platform-neutral; `createSupport` (native) builds\n * on it, and a web app can resolve it with `resolveBlocks` too.\n */\nimport { defineBlock, type BlockContributions, type BlockSettingsRow } from \"@marktiderman/genesis-features/blocks\";\nimport { resolveSupportCopy, type SupportCopyOverrides } from \"./copy\";\n\n/** The FeatureId of the support block (features.ts). */\nexport const SUPPORT_BLOCK_ID = \"support-feedback\";\n\n/** Where the block's screens live, under `basePath`. */\nexport interface SupportPaths {\n /** Help: the person's reports and links to the idea board. */\n inbox: string;\n /** The feedback form. */\n feedback: string;\n /** One report's thread: `thread(id)`. */\n thread: (id: string) => string;\n ideas: string;\n shipped: string;\n}\n\nexport function supportPaths(basePath = \"/support\"): SupportPaths {\n const base = basePath.replace(/\\/$/, \"\") || \"/support\";\n return {\n inbox: base,\n feedback: `${base}/new`,\n thread: (id) => `${base}/${encodeURIComponent(id)}`,\n ideas: `${base}/ideas`,\n shipped: `${base}/shipped`,\n };\n}\n\nexport interface SupportBlockOptions {\n /** Default `/support`. The app owns the route files under it. */\n basePath?: string;\n copy?: SupportCopyOverrides;\n /** The Settings rows' section (default `support`), or `false` for none. */\n settings?: false | { section?: string };\n /** A subtitle hook for the Help row (an unread count). Called on every Settings render. */\n useHelpSubtitle?: () => string | null | undefined;\n /** `false` leaves the idea board out. Default on. */\n ideas?: boolean;\n /** The block's other FeatureIds it needs (default `[\"account\"]`: a signed-in person). */\n requires?: readonly string[];\n}\n\nconst PACKAGE = \"@marktiderman/genesis-supabase\";\n\n/** The block's contributions, for any platform. */\nexport function supportContributions(options: SupportBlockOptions = {}): BlockContributions<never> {\n const copy = resolveSupportCopy(options.copy);\n const paths = supportPaths(options.basePath);\n const section = options.settings === false ? null : (options.settings?.section ?? \"support\");\n const settings: BlockSettingsRow[] = section\n ? [\n { id: \"support-send-feedback\", section, title: copy.settings.sendFeedback, href: paths.feedback, order: 10, testID: \"genesis-support-send-feedback\" },\n { id: \"support-help\", section, title: copy.settings.help, href: paths.inbox, order: 20, testID: \"genesis-support-help\", useSubtitle: options.useHelpSubtitle },\n ]\n : [];\n const routes = [\n { path: paths.inbox, description: \"the person's reports and staff replies\", signedIn: true },\n { path: paths.feedback, description: \"send feedback, with a screenshot\", signedIn: true },\n { path: `${paths.inbox}/[id]`, description: \"one report's conversation\", signedIn: true },\n ...(options.ideas === false\n ? []\n : [\n { path: paths.ideas, description: \"the idea board, with votes\", signedIn: true },\n { path: paths.shipped, description: \"ideas the person asked for that shipped\", signedIn: true },\n ]),\n ];\n return {\n settings,\n routes,\n migrations: [{ package: PACKAGE, file: \"migrations/0006_genesis_support.sql\", test: \"migrations/tests/0006_genesis_support.test.sql\" }],\n privacy: {\n collects: [\n { type: \"user ID\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"customer support\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"other user content\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"photos or videos\", purposes: [\"app functionality\"], linked: true, tracking: false },\n { type: \"other diagnostic data\", purposes: [\"app functionality\"], linked: true, tracking: false },\n ],\n notes: [\n \"Feedback is sent only when the person taps Send, and a screenshot only when they include it, after they can hide parts of it.\",\n \"Reports, screenshots and replies are visible to the person and the app's support staff only, and go when the person deletes them or their account.\",\n \"Feedback includes the app version, the screen's path (never its query string; private-looking segments are replaced), the platform and basic device facts.\",\n ],\n },\n };\n}\n\n/** The platform-neutral declaration. */\nexport function defineSupportBlock(options: SupportBlockOptions = {}) {\n return defineBlock({\n id: SUPPORT_BLOCK_ID,\n requires: options.requires ?? [\"account\"],\n contributes: supportContributions(options),\n });\n}\n"]}
|