@heedkit/sdk-react-native 0.1.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.
@@ -0,0 +1,330 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { HeedKitClient, type InitResult } from "./client";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // fetch mocking helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ type FetchCall = { url: string; init?: RequestInit };
10
+
11
+ function mockFetch(handler: (call: FetchCall) => Response | Promise<Response>) {
12
+ const calls: FetchCall[] = [];
13
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
14
+ const call = { url, init };
15
+ calls.push(call);
16
+ return handler(call);
17
+ });
18
+ // @ts-expect-error — overriding global fetch for the test
19
+ globalThis.fetch = fetchMock;
20
+ return { calls, fetchMock };
21
+ }
22
+
23
+ function jsonResponse(body: unknown, status = 200): Response {
24
+ return new Response(JSON.stringify(body), {
25
+ status,
26
+ headers: { "Content-Type": "application/json" },
27
+ });
28
+ }
29
+
30
+ // The Rails /sdk/init response nests project config under `project`.
31
+ const FULL_INIT_RESPONSE: InitResult = {
32
+ end_user_id: "eu-alice",
33
+ project: {
34
+ name: "Test",
35
+ theme: { primary: "#000000" },
36
+ enabled_kinds: ["feature_request", "bug_report", "improvement", "appreciation", "other"],
37
+ kind_visibility: {
38
+ feature_request: "public",
39
+ bug_report: "private",
40
+ improvement: "private",
41
+ appreciation: "private",
42
+ other: "private",
43
+ },
44
+ kind_interactions: {
45
+ feature_request: { upvote: true, downvote: false },
46
+ bug_report: { plus_one: true },
47
+ improvement: { upvote: true, downvote: false },
48
+ appreciation: { like: true },
49
+ other: { like: true },
50
+ },
51
+ is_public_roadmap: false,
52
+ },
53
+ };
54
+
55
+ let originalFetch: typeof fetch;
56
+
57
+ beforeEach(() => {
58
+ originalFetch = globalThis.fetch;
59
+ });
60
+ afterEach(() => {
61
+ globalThis.fetch = originalFetch;
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // init() — payload parsing + state hydration
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe("HeedKitClient.init", () => {
70
+ it("posts identity to /sdk/init with the project key header", async () => {
71
+ const { calls } = mockFetch(() => jsonResponse(FULL_INIT_RESPONSE));
72
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
73
+
74
+ await client.init({ externalId: "alice", email: "alice@x.com", platform: "tests" });
75
+
76
+ expect(calls).toHaveLength(1);
77
+ expect(calls[0].url).toBe("http://api/sdk/init");
78
+ const init = calls[0].init!;
79
+ expect(init.method).toBe("POST");
80
+ // init.headers may be a Headers instance OR a plain object depending on the runtime.
81
+ const headers = new Headers(init.headers as HeadersInit);
82
+ expect(headers.get("X-Project-Key")).toBe("fh_test");
83
+ expect(headers.get("Content-Type")).toBe("application/json");
84
+ expect(JSON.parse(init.body as string)).toEqual({
85
+ external_id: "alice",
86
+ email: "alice@x.com",
87
+ name: undefined,
88
+ avatar_url: undefined,
89
+ platform: "tests",
90
+ });
91
+ });
92
+
93
+ it("defaults platform to 'web' when no user is provided", async () => {
94
+ const { calls } = mockFetch(() => jsonResponse(FULL_INIT_RESPONSE));
95
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
96
+ await client.init();
97
+ const body = JSON.parse(calls[0].init!.body as string);
98
+ expect(body.platform).toBe("web");
99
+ });
100
+
101
+ it("hydrates theme / enabledKinds / kindVisibility / kindInteractions / endUserId", async () => {
102
+ mockFetch(() => jsonResponse(FULL_INIT_RESPONSE));
103
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
104
+
105
+ await client.init({ externalId: "alice" });
106
+
107
+ expect(client.getEndUserId()).toBe("eu-alice");
108
+ expect(client.getProjectName()).toBe("Test");
109
+ expect(client.getTheme()).toEqual({ primary: "#000000" });
110
+ expect(client.getEnabledKinds()).toContain("feature_request");
111
+ expect(client.getKindVisibility().bug_report).toBe("private");
112
+ expect(client.getKindInteractions().feature_request).toEqual({
113
+ upvote: true,
114
+ downvote: false,
115
+ });
116
+ });
117
+
118
+ it("tolerates an old server response missing kind_visibility / kind_interactions", async () => {
119
+ const partial = {
120
+ project_id: "p",
121
+ project_name: "Old",
122
+ theme: {},
123
+ enabled_kinds: ["feature_request"],
124
+ end_user_id: "eu",
125
+ };
126
+ mockFetch(() => jsonResponse(partial));
127
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
128
+
129
+ await client.init({ externalId: "alice" });
130
+ // Falls back to empty maps rather than throwing.
131
+ expect(client.getKindVisibility()).toEqual({});
132
+ expect(client.getKindInteractions()).toEqual({});
133
+ expect(client.getInteractionsFor("feature_request")).toEqual([]);
134
+ });
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // getInteractionsFor — canonical order + filtering
139
+ // ---------------------------------------------------------------------------
140
+
141
+ describe("getInteractionsFor", () => {
142
+ it("returns enabled interactions in canonical order", async () => {
143
+ mockFetch(() => jsonResponse(FULL_INIT_RESPONSE));
144
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
145
+ await client.init({ externalId: "alice" });
146
+
147
+ // Even though the JSON had `upvote: true, downvote: false`, the canonical
148
+ // order is [upvote, downvote, plus_one, like] — only enabled ones surface.
149
+ expect(client.getInteractionsFor("feature_request")).toEqual(["upvote"]);
150
+ expect(client.getInteractionsFor("bug_report")).toEqual(["plus_one"]);
151
+ expect(client.getInteractionsFor("appreciation")).toEqual(["like"]);
152
+ });
153
+
154
+ it("returns up+down together when both enabled", async () => {
155
+ mockFetch(() => jsonResponse({
156
+ ...FULL_INIT_RESPONSE,
157
+ project: {
158
+ ...FULL_INIT_RESPONSE.project,
159
+ kind_interactions: {
160
+ ...FULL_INIT_RESPONSE.project.kind_interactions,
161
+ feature_request: { upvote: true, downvote: true },
162
+ },
163
+ },
164
+ }));
165
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
166
+ await client.init({ externalId: "alice" });
167
+ expect(client.getInteractionsFor("feature_request")).toEqual(["upvote", "downvote"]);
168
+ });
169
+
170
+ it("returns empty array for a kind that has no interactions enabled", async () => {
171
+ mockFetch(() => jsonResponse({
172
+ ...FULL_INIT_RESPONSE,
173
+ project: {
174
+ ...FULL_INIT_RESPONSE.project,
175
+ kind_interactions: { ...FULL_INIT_RESPONSE.project.kind_interactions, appreciation: { like: false } },
176
+ },
177
+ }));
178
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
179
+ await client.init({ externalId: "alice" });
180
+ expect(client.getInteractionsFor("appreciation")).toEqual([]);
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // list / submit / vote — URL + payload shape
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("list / submit / vote", () => {
189
+ async function newReady(handler: (call: FetchCall) => Response | Promise<Response>) {
190
+ const records = mockFetch(handler);
191
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
192
+ await client.init({ externalId: "alice" });
193
+ records.calls.length = 0;
194
+ return { client, calls: records.calls };
195
+ }
196
+
197
+ it("list sends end_user_id and any provided filters", async () => {
198
+ const { client, calls } = await newReady((call) => {
199
+ if (call.url.includes("/sdk/features")) return jsonResponse({ features: [], next_cursor: null });
200
+ return jsonResponse(FULL_INIT_RESPONSE);
201
+ });
202
+
203
+ await client.list({ status: "planned", kind: "bug_report", sort: "new" });
204
+ expect(calls).toHaveLength(1);
205
+ const url = new URL(calls[0].url);
206
+ expect(url.pathname).toBe("/sdk/features");
207
+ expect(url.searchParams.get("end_user_id")).toBe("eu-alice");
208
+ expect(url.searchParams.get("status")).toBe("planned");
209
+ expect(url.searchParams.get("kind")).toBe("bug_report");
210
+ expect(url.searchParams.get("sort")).toBe("new");
211
+ });
212
+
213
+ it("list unwraps { features } and maps `author` -> author_name", async () => {
214
+ const { client } = await newReady((call) => {
215
+ if (call.url.includes("/sdk/features"))
216
+ return jsonResponse({
217
+ features: [ { id: 7, title: "Dark mode", status: "planned", kind: "feature_request", visibility: "public", vote_count: 3, author: "Dana", created_at: "2026-01-01T00:00:00Z" } ],
218
+ next_cursor: null,
219
+ });
220
+ return jsonResponse(FULL_INIT_RESPONSE);
221
+ });
222
+ const features = await client.list();
223
+ expect(features).toHaveLength(1);
224
+ expect(features[0].id).toBe("7");
225
+ expect(features[0].author_name).toBe("Dana");
226
+ expect(features[0].voted).toBe(false);
227
+ expect(features[0].on_roadmap).toBe(false);
228
+ });
229
+
230
+ it("listComments unwraps { comments } and maps `author` -> author_name", async () => {
231
+ const { client } = await newReady((call) => {
232
+ if (call.url.includes("/comments"))
233
+ return jsonResponse({ comments: [ { id: 1, body: "yes please", author: "Sam", created_at: "2026-01-01T00:00:00Z" } ] });
234
+ return jsonResponse(FULL_INIT_RESPONSE);
235
+ });
236
+ const comments = await client.listComments("7");
237
+ expect(comments).toHaveLength(1);
238
+ expect(comments[0].author_name).toBe("Sam");
239
+ expect(comments[0].is_internal).toBe(false);
240
+ });
241
+
242
+ it("submit posts the correct body and defaults kind to feature_request", async () => {
243
+ const { client, calls } = await newReady((call) => {
244
+ if (call.url.endsWith("/sdk/features"))
245
+ return jsonResponse({
246
+ id: "f1",
247
+ title: "x",
248
+ description: "",
249
+ status: "open",
250
+ kind: "feature_request",
251
+ visibility: "public",
252
+ on_roadmap: false,
253
+ tag: null,
254
+ vote_count: 0,
255
+ voted: false,
256
+ platform: null,
257
+ author_name: null,
258
+ created_at: "2026-01-01T00:00:00Z",
259
+ });
260
+ return jsonResponse(FULL_INIT_RESPONSE);
261
+ });
262
+
263
+ await client.submit({ title: "Dark mode" });
264
+ expect(calls).toHaveLength(1);
265
+ expect(calls[0].init!.method).toBe("POST");
266
+ const body = JSON.parse(calls[0].init!.body as string);
267
+ expect(body).toEqual({
268
+ end_user_id: "eu-alice",
269
+ title: "Dark mode",
270
+ description: "",
271
+ tag: null,
272
+ kind: "feature_request",
273
+ });
274
+ });
275
+
276
+ it("vote posts to /sdk/features/{id}/vote", async () => {
277
+ const { client, calls } = await newReady((call) => {
278
+ if (call.url.endsWith("/vote"))
279
+ return jsonResponse({ voted: true, vote_count: 1 });
280
+ return jsonResponse(FULL_INIT_RESPONSE);
281
+ });
282
+ const out = await client.vote("feat-42");
283
+ expect(out).toEqual({ voted: true, vote_count: 1 });
284
+ expect(calls[0].url).toBe("http://api/sdk/features/feat-42/vote");
285
+ });
286
+
287
+ it("throws Error before init when calling list/submit/vote", async () => {
288
+ mockFetch(() => jsonResponse(FULL_INIT_RESPONSE));
289
+ const client = new HeedKitClient({ projectKey: "fh_test", apiUrl: "http://api" });
290
+ await expect(client.list()).rejects.toThrow(/not initialized/);
291
+ await expect(client.submit({ title: "x" })).rejects.toThrow(/not initialized/);
292
+ await expect(client.vote("x")).rejects.toThrow(/not initialized/);
293
+ });
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // HTTP error handling
298
+ // ---------------------------------------------------------------------------
299
+
300
+ describe("error handling", () => {
301
+ it("surfaces the Rails `error` code on a 4xx", async () => {
302
+ mockFetch(() =>
303
+ new Response(JSON.stringify({ error: "invalid_project_key" }), {
304
+ status: 401,
305
+ headers: { "Content-Type": "application/json" },
306
+ })
307
+ );
308
+ const client = new HeedKitClient({ projectKey: "bad", apiUrl: "http://api" });
309
+ await expect(client.init()).rejects.toThrow(/invalid_project_key/);
310
+ });
311
+
312
+ it("tolerates a legacy `detail` error field on a 4xx", async () => {
313
+ mockFetch(() =>
314
+ new Response(JSON.stringify({ detail: "Invalid project key" }), {
315
+ status: 401,
316
+ headers: { "Content-Type": "application/json" },
317
+ })
318
+ );
319
+ const client = new HeedKitClient({ projectKey: "bad", apiUrl: "http://api" });
320
+ await expect(client.init()).rejects.toThrow(/Invalid project key/);
321
+ });
322
+
323
+ it("falls back to HTTP <status> when the body isn't JSON", async () => {
324
+ mockFetch(() =>
325
+ new Response("Internal Server Error", { status: 500 })
326
+ );
327
+ const client = new HeedKitClient({ projectKey: "ok", apiUrl: "http://api" });
328
+ await expect(client.init()).rejects.toThrow(/HTTP 500/);
329
+ });
330
+ });
package/src/client.ts ADDED
@@ -0,0 +1,301 @@
1
+ export type Visibility = "public" | "private";
2
+
3
+ export type Interaction = "upvote" | "downvote" | "plus_one" | "like";
4
+
5
+ export type KindInteractions = Partial<Record<Interaction, boolean>>;
6
+
7
+ export type ShowCounts = Partial<Record<FeatureKind, boolean>>;
8
+
9
+ export type GroupMode = "tabs" | "list";
10
+
11
+ export type Theme = {
12
+ primary?: string;
13
+ primaryDark?: string;
14
+ radius?: number;
15
+ /// `"system"` follows the OS color scheme at render time.
16
+ mode?: "light" | "dark" | "system";
17
+ font_family?: string;
18
+ font_size?: "sm" | "md" | "lg";
19
+ group_mode?: GroupMode;
20
+ show_counts?: ShowCounts;
21
+ // Older deployments may still send camelCase keys for these — kept for backcompat.
22
+ fontFamily?: string;
23
+ };
24
+
25
+ export type EndUser = {
26
+ externalId?: string;
27
+ email?: string;
28
+ name?: string;
29
+ avatarUrl?: string;
30
+ platform?: string;
31
+ };
32
+
33
+ export type FeatureKind =
34
+ | "feature_request"
35
+ | "bug_report"
36
+ | "improvement"
37
+ | "appreciation"
38
+ | "other";
39
+
40
+ export type Feature = {
41
+ id: string;
42
+ title: string;
43
+ description: string;
44
+ status: "open" | "planned" | "in_progress" | "shipped" | "declined";
45
+ kind: FeatureKind;
46
+ /// Whether the item is visible beyond its author + the project team.
47
+ visibility: Visibility;
48
+ /// Whether the item appears on the project's roadmap (public if visibility=public).
49
+ on_roadmap: boolean;
50
+ tag: string | null;
51
+ vote_count: number;
52
+ voted: boolean;
53
+ platform: string | null;
54
+ author_name: string | null;
55
+ created_at: string;
56
+ };
57
+
58
+ export type Comment = {
59
+ id: string;
60
+ body: string;
61
+ author_name: string | null;
62
+ is_internal: boolean;
63
+ created_at: string;
64
+ };
65
+
66
+ export type HeedKitConfig = {
67
+ projectKey: string;
68
+ apiUrl?: string;
69
+ user?: EndUser;
70
+ };
71
+
72
+ /// Project configuration returned by /sdk/init (nested under `project`).
73
+ export type ProjectConfig = {
74
+ name: string;
75
+ theme: Theme;
76
+ enabled_kinds: FeatureKind[];
77
+ /// Default visibility applied to new submissions of each kind.
78
+ kind_visibility: Record<FeatureKind, Visibility>;
79
+ /// Which interactions admin has enabled per kind. The widget should only render
80
+ /// the affordances listed here.
81
+ kind_interactions: Record<FeatureKind, KindInteractions>;
82
+ is_public_roadmap?: boolean;
83
+ };
84
+
85
+ export type InitResult = {
86
+ end_user_id: string;
87
+ project: ProjectConfig;
88
+ };
89
+
90
+ const DEFAULT_API = "https://api.heedkit.com";
91
+
92
+ /**
93
+ * Optional storage shim for the device id. React Native doesn't ship with a
94
+ * synchronous KV store; pass `@react-native-async-storage/async-storage` (or
95
+ * any equivalent) once at app startup and the client will use it to persist
96
+ * the per-device id across launches.
97
+ *
98
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
99
+ * import { setDeviceStorage } from "@heedkit/sdk-react-native";
100
+ * setDeviceStorage(AsyncStorage);
101
+ *
102
+ * If you don't, an in-memory id is used — fine for a session but resets on
103
+ * cold launch.
104
+ */
105
+ export interface DeviceStorage {
106
+ getItem(key: string): Promise<string | null>;
107
+ setItem(key: string, value: string): Promise<void>;
108
+ }
109
+
110
+ const DEVICE_ID_KEY = "heedkit.device_id";
111
+ let _storage: DeviceStorage | null = null;
112
+ let _memoryDeviceId: string | null = null;
113
+
114
+ export function setDeviceStorage(s: DeviceStorage): void {
115
+ _storage = s;
116
+ }
117
+
118
+ async function getOrCreateDeviceId(): Promise<string> {
119
+ if (_storage) {
120
+ try {
121
+ const existing = await _storage.getItem(DEVICE_ID_KEY);
122
+ if (existing) return existing;
123
+ const next = _newId();
124
+ await _storage.setItem(DEVICE_ID_KEY, next);
125
+ return next;
126
+ } catch {
127
+ // Storage error — fall through to memory.
128
+ }
129
+ }
130
+ if (!_memoryDeviceId) _memoryDeviceId = _newId();
131
+ return _memoryDeviceId;
132
+ }
133
+
134
+ function _newId(): string {
135
+ // Avoid the crypto.randomUUID polyfill dance on RN — a 16-byte hex is plenty.
136
+ const bytes = new Uint8Array(16);
137
+ for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
138
+ return "dev_" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
139
+ }
140
+
141
+ /// Map a raw API feature onto the SDK shape (the backend compacts null fields and
142
+ /// exposes the author display name as `author`).
143
+ function normalizeFeature(f: any): Feature {
144
+ return {
145
+ id: String(f.id),
146
+ title: f.title,
147
+ description: f.description ?? "",
148
+ status: f.status,
149
+ kind: f.kind,
150
+ visibility: f.visibility,
151
+ on_roadmap: f.on_roadmap ?? false,
152
+ tag: f.tag ?? null,
153
+ vote_count: f.vote_count ?? 0,
154
+ voted: f.voted ?? false,
155
+ platform: f.platform ?? null,
156
+ author_name: f.author_name ?? f.author ?? null,
157
+ created_at: f.created_at,
158
+ };
159
+ }
160
+
161
+ function normalizeComment(c: any): Comment {
162
+ return {
163
+ id: String(c.id),
164
+ body: c.body,
165
+ author_name: c.author_name ?? c.author ?? null,
166
+ // The SDK endpoint only ever returns public comments.
167
+ is_internal: c.is_internal ?? false,
168
+ created_at: c.created_at,
169
+ };
170
+ }
171
+
172
+ export class HeedKitClient {
173
+ private apiUrl: string;
174
+ private projectKey: string;
175
+ private endUserId: string | null = null;
176
+ private theme: Theme = {};
177
+ private projectName = "";
178
+ private enabledKinds: FeatureKind[] = [];
179
+ private kindVisibility: Partial<Record<FeatureKind, Visibility>> = {};
180
+ private kindInteractions: Partial<Record<FeatureKind, KindInteractions>> = {};
181
+
182
+ constructor(config: HeedKitConfig) {
183
+ this.apiUrl = config.apiUrl || DEFAULT_API;
184
+ this.projectKey = config.projectKey;
185
+ }
186
+
187
+ async init(user: EndUser = {}): Promise<InitResult> {
188
+ const externalId = user.externalId ?? (await getOrCreateDeviceId());
189
+ const body = {
190
+ external_id: externalId,
191
+ email: user.email,
192
+ name: user.name,
193
+ avatar_url: user.avatarUrl,
194
+ platform: user.platform || "web",
195
+ };
196
+ const res = await this.request<InitResult>("/sdk/init", "POST", body);
197
+ this.endUserId = res.end_user_id;
198
+ // The Rails backend nests project config under `project`; tolerate a flat
199
+ // response from older deployments too.
200
+ const p: any = (res as any).project ?? res;
201
+ this.theme = p.theme || {};
202
+ this.projectName = p.name ?? p.project_name ?? "";
203
+ this.enabledKinds = p.enabled_kinds || [];
204
+ this.kindVisibility = p.kind_visibility || {};
205
+ this.kindInteractions = p.kind_interactions || {};
206
+ return res;
207
+ }
208
+
209
+ getTheme() { return this.theme; }
210
+ getEnabledKinds(): FeatureKind[] { return this.enabledKinds; }
211
+ getKindVisibility() { return this.kindVisibility; }
212
+ getKindInteractions() { return this.kindInteractions; }
213
+ getProjectName() { return this.projectName; }
214
+ getEndUserId() { return this.endUserId; }
215
+
216
+ /// Convenience: which interactions are enabled for a given kind, in stable order.
217
+ getInteractionsFor(kind: FeatureKind): Interaction[] {
218
+ const row = this.kindInteractions[kind] || {};
219
+ return (["upvote", "downvote", "plus_one", "like"] as Interaction[]).filter(
220
+ (i) => row[i]
221
+ );
222
+ }
223
+
224
+ async list(
225
+ opts: { status?: string; kind?: FeatureKind; sort?: "top" | "new" } = {}
226
+ ): Promise<Feature[]> {
227
+ this.ensureInit();
228
+ const params = new URLSearchParams({ end_user_id: this.endUserId! });
229
+ if (opts.status) params.set("status", opts.status);
230
+ if (opts.kind) params.set("kind", opts.kind);
231
+ if (opts.sort) params.set("sort", opts.sort);
232
+ // Rails returns { features, next_cursor }; tolerate a bare array too.
233
+ const res = await this.request<any>(`/sdk/features?${params}`, "GET");
234
+ const features = Array.isArray(res) ? res : (res.features ?? []);
235
+ return features.map((f: any) => normalizeFeature(f));
236
+ }
237
+
238
+ async submit(input: {
239
+ title: string;
240
+ description?: string;
241
+ tag?: string;
242
+ kind?: FeatureKind;
243
+ }): Promise<Feature> {
244
+ this.ensureInit();
245
+ const res = await this.request<any>("/sdk/features", "POST", {
246
+ end_user_id: this.endUserId,
247
+ title: input.title,
248
+ description: input.description || "",
249
+ tag: input.tag || null,
250
+ kind: input.kind || "feature_request",
251
+ });
252
+ return normalizeFeature(res);
253
+ }
254
+
255
+ async vote(featureId: string): Promise<{ voted: boolean; vote_count: number }> {
256
+ this.ensureInit();
257
+ return this.request(`/sdk/features/${featureId}/vote`, "POST", {
258
+ end_user_id: this.endUserId,
259
+ });
260
+ }
261
+
262
+ async listComments(featureId: string): Promise<Comment[]> {
263
+ // Rails returns { comments: [...] }; tolerate a bare array too.
264
+ const res = await this.request<any>(`/sdk/features/${featureId}/comments`, "GET");
265
+ const comments = Array.isArray(res) ? res : (res.comments ?? []);
266
+ return comments.map((c: any) => normalizeComment(c));
267
+ }
268
+
269
+ async comment(featureId: string, body: string): Promise<Comment> {
270
+ this.ensureInit();
271
+ const res = await this.request<any>(`/sdk/features/${featureId}/comments`, "POST", {
272
+ end_user_id: this.endUserId,
273
+ body,
274
+ });
275
+ return normalizeComment(res);
276
+ }
277
+
278
+ private ensureInit() {
279
+ if (!this.endUserId) throw new Error("HeedKit not initialized — call init() first");
280
+ }
281
+
282
+ private async request<T>(path: string, method: string, body?: unknown): Promise<T> {
283
+ const res = await fetch(`${this.apiUrl}${path}`, {
284
+ method,
285
+ headers: {
286
+ "Content-Type": "application/json",
287
+ "X-Project-Key": this.projectKey,
288
+ },
289
+ body: body ? JSON.stringify(body) : undefined,
290
+ });
291
+ if (!res.ok) {
292
+ let detail = `HTTP ${res.status}`;
293
+ try {
294
+ const j = await res.json();
295
+ detail = j.error || j.detail || detail; // Rails uses `error`; legacy used `detail`.
296
+ } catch { /* non-JSON body */ }
297
+ throw new Error(detail);
298
+ }
299
+ return res.json() as Promise<T>;
300
+ }
301
+ }