@fewchurs/react 0.0.0-stage → 1.0.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/dist/index.js ADDED
@@ -0,0 +1,1823 @@
1
+ "use client";
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/index.ts
22
+ var src_exports = {};
23
+ __export(src_exports, {
24
+ BoardRoot: () => BoardRoot,
25
+ BoardTabs: () => BoardTabs,
26
+ Branding: () => Branding,
27
+ CommentForm: () => CommentForm,
28
+ CommentList: () => CommentList,
29
+ DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
30
+ DEFAULT_LABELS: () => DEFAULT_LABELS,
31
+ DEFAULT_PAGE_SIZE: () => DEFAULT_PAGE_SIZE,
32
+ DEVICE_ID_COOKIE: () => COOKIE_NAME,
33
+ FeatureCard: () => FeatureCard,
34
+ FeatureList: () => FeatureList,
35
+ FewchursBoard: () => FewchursBoard,
36
+ FewchursBoardDialog: () => FewchursBoardDialog,
37
+ FewchursProvider: () => FewchursProvider,
38
+ FollowForm: () => FollowForm,
39
+ LIMITS: () => LIMITS,
40
+ ROADMAP_STATUSES: () => ROADMAP_STATUSES,
41
+ SDK_HEADER: () => SDK_HEADER,
42
+ SDK_VERSION: () => SDK_VERSION,
43
+ StatusBadge: () => StatusBadge,
44
+ SubmitForm: () => SubmitForm,
45
+ SubmitTrigger: () => SubmitTrigger,
46
+ VoteButton: () => VoteButton,
47
+ commentsStateOf: () => commentsStateOf,
48
+ createDeviceId: () => createDeviceId,
49
+ createFewchursClient: () => createFewchursClient,
50
+ createStore: () => createStore,
51
+ errorFromResponse: () => errorFromResponse,
52
+ featureKey: () => featureKey,
53
+ listKey: () => listKey,
54
+ listStateOf: () => listStateOf,
55
+ mergeLabels: () => mergeLabels,
56
+ networkError: () => networkError,
57
+ normalizeComment: () => normalizeComment,
58
+ normalizeCommentPage: () => normalizeCommentPage,
59
+ normalizeConfig: () => normalizeConfig,
60
+ normalizeFeature: () => normalizeFeature,
61
+ normalizeFeaturePage: () => normalizeFeaturePage,
62
+ normalizeStatus: () => normalizeStatus,
63
+ normalizeVote: () => normalizeVote,
64
+ readDeviceId: () => readDeviceId,
65
+ resolveDeviceId: () => resolveDeviceId,
66
+ useBoard: () => useBoard,
67
+ useComments: () => useComments,
68
+ useFeature: () => useFeature,
69
+ useFeatures: () => useFeatures,
70
+ useFewchurs: () => useFewchurs,
71
+ useFewchursConfig: () => useFewchursConfig,
72
+ useFollow: () => useFollow,
73
+ useSubmitFeature: () => useSubmitFeature,
74
+ useVote: () => useVote,
75
+ userMessage: () => userMessage,
76
+ validateCommentInput: () => validateCommentInput,
77
+ validateEmail: () => validateEmail,
78
+ validateFeatureInput: () => validateFeatureInput,
79
+ validationError: () => validationError
80
+ });
81
+ module.exports = __toCommonJS(src_exports);
82
+
83
+ // src/context/provider.tsx
84
+ var import_react = require("react");
85
+
86
+ // src/client/version.ts
87
+ var SDK_VERSION = "1.0.0";
88
+
89
+ // src/client/constants.ts
90
+ var SDK_HEADER = `react/${SDK_VERSION}`;
91
+ var DEFAULT_BASE_URL = "https://www.fewchurs.shop/api/v1";
92
+ var DEFAULT_PAGE_SIZE = 20;
93
+ var LIMITS = {
94
+ titleMin: 3,
95
+ titleMax: 100,
96
+ descriptionMax: 1e3,
97
+ commentMin: 1,
98
+ commentMax: 1e3,
99
+ authorNameMax: 60
100
+ };
101
+ var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
102
+ var ROADMAP_STATUSES = ["planned", "in_progress", "done"];
103
+
104
+ // src/client/errors.ts
105
+ var USER_MESSAGES = {
106
+ invalid_key: "Feedback is unavailable right now.",
107
+ validation_failed: "Please check what you entered and try again.",
108
+ rate_limited: "You have reached today's limit. Try again tomorrow.",
109
+ not_found: "That request no longer exists.",
110
+ limit_reached: "Feedback is unavailable right now.",
111
+ server: "Something went wrong. Please try again.",
112
+ network: "Could not reach the server. Check your connection."
113
+ };
114
+ function userMessage(error) {
115
+ if (error.code === "validation_failed" && error.message) return error.message;
116
+ return USER_MESSAGES[error.code];
117
+ }
118
+ function networkError(message = "Network request failed.") {
119
+ return { code: "network", message };
120
+ }
121
+ function validationError(message) {
122
+ return { code: "validation_failed", status: 400, message };
123
+ }
124
+ function errorFromResponse(status, body) {
125
+ const code = body?.error?.code;
126
+ const message = body?.error?.message ?? `Request failed (${status}).`;
127
+ switch (code) {
128
+ case "invalid_key":
129
+ return { code: "invalid_key", status: 401, message };
130
+ // missing_device_id is a client mistake the caller fixes the same way a bad field is fixed,
131
+ // so it collapses into validation_failed rather than widening the public union.
132
+ case "missing_device_id":
133
+ case "validation_failed":
134
+ return { code: "validation_failed", status: 400, message };
135
+ case "rate_limited":
136
+ return { code: "rate_limited", status: 429, message };
137
+ case "not_found":
138
+ return { code: "not_found", status: 404, message };
139
+ case "limit_reached":
140
+ return { code: "limit_reached", status: 402, message };
141
+ default:
142
+ return status === 401 ? { code: "invalid_key", status: 401, message } : { code: "server", status, message };
143
+ }
144
+ }
145
+
146
+ // src/client/normalize.ts
147
+ var KNOWN_STATUSES = /* @__PURE__ */ new Set(["open", "planned", "in_progress", "done", "declined"]);
148
+ function asRecord(value) {
149
+ return typeof value === "object" && value !== null ? value : {};
150
+ }
151
+ function str(value, fallback = "") {
152
+ return typeof value === "string" ? value : fallback;
153
+ }
154
+ function num(value) {
155
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
156
+ }
157
+ function bool(value) {
158
+ return value === true;
159
+ }
160
+ function normalizeStatus(value) {
161
+ return typeof value === "string" && KNOWN_STATUSES.has(value) ? value : "unknown";
162
+ }
163
+ function isMine(raw, ownerKey, deviceId) {
164
+ if (typeof raw.isMine === "boolean") return raw.isMine;
165
+ const owner = raw[ownerKey];
166
+ return typeof owner === "string" && owner.length > 0 && owner === deviceId;
167
+ }
168
+ function normalizeFeature(value, deviceId) {
169
+ const raw = asRecord(value);
170
+ return {
171
+ id: str(raw.id),
172
+ title: str(raw.title),
173
+ description: str(raw.description),
174
+ status: normalizeStatus(raw.status),
175
+ upvoteCount: num(raw.upvoteCount),
176
+ commentCount: num(raw.commentCount),
177
+ followerCount: num(raw.followerCount),
178
+ hasVoted: bool(raw.hasVoted),
179
+ isFollowing: bool(raw.isFollowing),
180
+ isMine: isMine(raw, "authorDeviceId", deviceId),
181
+ createdAt: str(raw.createdAt),
182
+ updatedAt: str(raw.updatedAt, str(raw.createdAt))
183
+ };
184
+ }
185
+ function normalizeComment(value, deviceId) {
186
+ const raw = asRecord(value);
187
+ return {
188
+ id: str(raw.id),
189
+ text: str(raw.text),
190
+ authorName: str(raw.authorName, "App User"),
191
+ isDeveloper: bool(raw.isDeveloper),
192
+ isMine: isMine(raw, "deviceId", deviceId),
193
+ createdAt: str(raw.createdAt)
194
+ };
195
+ }
196
+ function normalizeFeaturePage(value, deviceId) {
197
+ const raw = asRecord(value);
198
+ const features = Array.isArray(raw.features) ? raw.features : [];
199
+ return {
200
+ features: features.map((feature) => normalizeFeature(feature, deviceId)),
201
+ nextCursor: typeof raw.nextCursor === "string" ? raw.nextCursor : null
202
+ };
203
+ }
204
+ function normalizeCommentPage(value, deviceId) {
205
+ const raw = asRecord(value);
206
+ const comments = Array.isArray(raw.comments) ? raw.comments : [];
207
+ return {
208
+ comments: comments.map((comment) => normalizeComment(comment, deviceId)),
209
+ nextCursor: typeof raw.nextCursor === "string" ? raw.nextCursor : null
210
+ };
211
+ }
212
+ function normalizeConfig(value) {
213
+ const raw = asRecord(value);
214
+ return {
215
+ showBranding: raw.showBranding !== false,
216
+ hideVoteCounts: bool(raw.hideVoteCounts),
217
+ appName: typeof raw.appName === "string" && raw.appName ? raw.appName : null
218
+ };
219
+ }
220
+ function normalizeVote(value) {
221
+ const raw = asRecord(value);
222
+ return { upvoteCount: num(raw.upvoteCount), hasVoted: bool(raw.hasVoted) };
223
+ }
224
+
225
+ // src/client/validate.ts
226
+ function validateFeatureInput(input) {
227
+ const title = input.title.trim();
228
+ if (title.length < LIMITS.titleMin) {
229
+ return validationError(`Title must be at least ${LIMITS.titleMin} characters.`);
230
+ }
231
+ if (title.length > LIMITS.titleMax) {
232
+ return validationError(`Title must be at most ${LIMITS.titleMax} characters.`);
233
+ }
234
+ if ((input.description ?? "").trim().length > LIMITS.descriptionMax) {
235
+ return validationError(`Description must be at most ${LIMITS.descriptionMax} characters.`);
236
+ }
237
+ if (input.email !== void 0 && input.email !== "" && !isEmail(input.email)) {
238
+ return validationError("Enter a valid email address.");
239
+ }
240
+ return null;
241
+ }
242
+ function validateCommentInput(input) {
243
+ const text = input.text.trim();
244
+ if (text.length < LIMITS.commentMin) return validationError("Write a comment first.");
245
+ if (text.length > LIMITS.commentMax) {
246
+ return validationError(`Comment must be at most ${LIMITS.commentMax} characters.`);
247
+ }
248
+ if ((input.authorName ?? "").trim().length > LIMITS.authorNameMax) {
249
+ return validationError(`Name must be at most ${LIMITS.authorNameMax} characters.`);
250
+ }
251
+ return null;
252
+ }
253
+ function validateEmail(email) {
254
+ return isEmail(email) ? null : validationError("Enter a valid email address.");
255
+ }
256
+ function isEmail(email) {
257
+ return EMAIL_PATTERN.test(email.trim());
258
+ }
259
+
260
+ // src/client/client.ts
261
+ function createFewchursClient(options = {}) {
262
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
263
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
264
+ const retries = options.retries ?? 1;
265
+ const retryDelayMs = options.retryDelayMs ?? 250;
266
+ function getDeviceId() {
267
+ return typeof options.deviceId === "function" ? options.deviceId() : options.deviceId ?? null;
268
+ }
269
+ function headers(hasBody) {
270
+ const result = {
271
+ Accept: "application/json",
272
+ "X-Fewchurs-Sdk": SDK_HEADER
273
+ };
274
+ if (hasBody) result["Content-Type"] = "application/json";
275
+ if (options.apiKey) result.Authorization = `Bearer ${options.apiKey}`;
276
+ const deviceId = getDeviceId();
277
+ if (deviceId) result["X-Device-Id"] = deviceId;
278
+ if (options.language) result["Accept-Language"] = options.language;
279
+ return result;
280
+ }
281
+ function url(path, query) {
282
+ const search = new URLSearchParams();
283
+ for (const [key, value] of Object.entries(query ?? {})) {
284
+ if (value !== void 0 && value !== null && value !== "") search.set(key, String(value));
285
+ }
286
+ const qs = search.toString();
287
+ return `${baseUrl}${path}${qs ? `?${qs}` : ""}`;
288
+ }
289
+ async function request(path, map, { method = "GET", body, query, signal } = {}) {
290
+ if (!doFetch) return { ok: false, error: networkError("No fetch implementation available.") };
291
+ const target = url(path, query);
292
+ const init = { method, headers: headers(body !== void 0) };
293
+ if (body !== void 0) init.body = JSON.stringify(body);
294
+ if (signal) init.signal = signal;
295
+ const attempts = method === "GET" ? retries + 1 : 1;
296
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
297
+ let response;
298
+ try {
299
+ response = await doFetch(target, init);
300
+ } catch (error) {
301
+ if (signal?.aborted) return { ok: false, error: networkError("Request aborted.") };
302
+ if (attempt < attempts - 1) {
303
+ await delay(retryDelayMs);
304
+ continue;
305
+ }
306
+ return { ok: false, error: networkError(messageOf(error)) };
307
+ }
308
+ if (response.ok) {
309
+ const payload = await readJson(response);
310
+ return { ok: true, data: map(payload) };
311
+ }
312
+ if (response.status >= 500 && attempt < attempts - 1) {
313
+ await delay(retryDelayMs);
314
+ continue;
315
+ }
316
+ const failure = errorFromResponse(response.status, await readJson(response));
317
+ warnAboutKey(failure.code);
318
+ return { ok: false, error: failure };
319
+ }
320
+ return { ok: false, error: networkError() };
321
+ }
322
+ return {
323
+ baseUrl,
324
+ getDeviceId,
325
+ getConfig: (signal) => request("/config", normalizeConfig, { signal }),
326
+ listFeatures: (params = {}, signal) => request("/features", (body) => normalizeFeaturePage(body, getDeviceId()), {
327
+ query: {
328
+ sort: params.sort ?? "new",
329
+ limit: params.limit ?? DEFAULT_PAGE_SIZE,
330
+ cursor: params.cursor ?? void 0,
331
+ status: params.status?.length ? params.status.join(",") : void 0
332
+ },
333
+ signal
334
+ }),
335
+ getFeature: (featureId, signal) => request(
336
+ `/features/${encodeURIComponent(featureId)}`,
337
+ (body) => normalizeFeature(body, getDeviceId()),
338
+ { signal }
339
+ ),
340
+ submitFeature: async (input) => {
341
+ const invalid = validateFeatureInput(input);
342
+ if (invalid) return { ok: false, error: invalid };
343
+ return request("/features", (body) => normalizeFeature(body, getDeviceId()), {
344
+ method: "POST",
345
+ body: {
346
+ title: input.title.trim(),
347
+ description: (input.description ?? "").trim(),
348
+ ...input.email ? { email: input.email.trim() } : {},
349
+ isSubscriber: input.isSubscriber ?? false
350
+ }
351
+ });
352
+ },
353
+ vote: (featureId) => request(`/features/${encodeURIComponent(featureId)}/vote`, normalizeVote, { method: "POST" }),
354
+ unvote: (featureId) => request(`/features/${encodeURIComponent(featureId)}/vote`, normalizeVote, {
355
+ method: "DELETE"
356
+ }),
357
+ follow: async (featureId, email) => {
358
+ const invalid = validateEmail(email);
359
+ if (invalid) return { ok: false, error: invalid };
360
+ return request(
361
+ `/features/${encodeURIComponent(featureId)}/follow`,
362
+ () => ({ following: true }),
363
+ {
364
+ method: "POST",
365
+ body: { email: email.trim() }
366
+ }
367
+ );
368
+ },
369
+ unfollow: (featureId) => request(`/features/${encodeURIComponent(featureId)}/follow`, () => ({ following: false }), {
370
+ method: "DELETE"
371
+ }),
372
+ listComments: (featureId, params = {}, signal) => request(
373
+ `/features/${encodeURIComponent(featureId)}/comments`,
374
+ (body) => normalizeCommentPage(body, getDeviceId()),
375
+ {
376
+ query: { limit: params.limit ?? DEFAULT_PAGE_SIZE, cursor: params.cursor ?? void 0 },
377
+ signal
378
+ }
379
+ ),
380
+ // The server takes the feature id in the body here; POST /features/{id}/comments is a 405.
381
+ createComment: async (input) => {
382
+ const invalid = validateCommentInput(input);
383
+ if (invalid) return { ok: false, error: invalid };
384
+ return request("/comments", (body) => normalizeComment(body, getDeviceId()), {
385
+ method: "POST",
386
+ body: {
387
+ featureId: input.featureId,
388
+ text: input.text.trim(),
389
+ ...input.authorName?.trim() ? { authorName: input.authorName.trim() } : {}
390
+ }
391
+ });
392
+ }
393
+ };
394
+ }
395
+ async function readJson(response) {
396
+ try {
397
+ return await response.json();
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ function delay(ms) {
403
+ return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
404
+ }
405
+ function messageOf(error) {
406
+ return error instanceof Error ? error.message : "Network request failed.";
407
+ }
408
+ var warnedAboutKey = false;
409
+ function warnAboutKey(code) {
410
+ if (code !== "invalid_key" || warnedAboutKey) return;
411
+ if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
412
+ warnedAboutKey = true;
413
+ console.error(
414
+ "[fewchurs] The API key was rejected. Check FEWCHURS_API_KEY (server) or the apiKey passed to FewchursProvider, and that the key is active in your Fewchurs dashboard."
415
+ );
416
+ }
417
+
418
+ // src/identity/deviceId.ts
419
+ var COOKIE_NAME = "fewchurs_did";
420
+ var STORAGE_KEY = "fewchurs_device_id";
421
+ var COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
422
+ function createDeviceId() {
423
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
424
+ return crypto.randomUUID();
425
+ }
426
+ return `fw-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
427
+ }
428
+ function readDeviceId() {
429
+ if (typeof document === "undefined") return null;
430
+ return readCookie() ?? readStorage();
431
+ }
432
+ function resolveDeviceId() {
433
+ const existing = readDeviceId();
434
+ if (existing) {
435
+ persist(existing);
436
+ return existing;
437
+ }
438
+ const created = createDeviceId();
439
+ persist(created);
440
+ return created;
441
+ }
442
+ function persist(deviceId) {
443
+ if (typeof document === "undefined") return;
444
+ try {
445
+ document.cookie = `${COOKIE_NAME}=${encodeURIComponent(deviceId)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
446
+ } catch {
447
+ }
448
+ try {
449
+ localStorage.setItem(STORAGE_KEY, deviceId);
450
+ } catch {
451
+ }
452
+ }
453
+ function readCookie() {
454
+ const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`));
455
+ return match?.[1] ? decodeURIComponent(match[1]) : null;
456
+ }
457
+ function readStorage() {
458
+ try {
459
+ return localStorage.getItem(STORAGE_KEY);
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ // src/store/keys.ts
466
+ function listKey({ sort, status }) {
467
+ return status?.length ? `${sort}:${[...status].sort().join(",")}` : sort;
468
+ }
469
+ function featureKey(featureId) {
470
+ return `feature:${featureId}`;
471
+ }
472
+
473
+ // src/store/store.ts
474
+ var EMPTY_LIST = { features: [], cursor: null, status: "idle", error: null };
475
+ var EMPTY_COMMENTS = { comments: [], cursor: null, status: "idle", error: null };
476
+ var MAX_EMPTY_PAGES = 5;
477
+ function createStore(client, { pageSize = 20, initialState, onEvent } = {}) {
478
+ let state = {
479
+ config: initialState?.config ?? null,
480
+ lists: initialState?.lists ?? {},
481
+ comments: initialState?.comments ?? {}
482
+ };
483
+ const listeners = /* @__PURE__ */ new Set();
484
+ const inFlight = /* @__PURE__ */ new Map();
485
+ function setState(next) {
486
+ state = { ...state, ...next };
487
+ for (const listener of listeners) listener();
488
+ }
489
+ function setList(key, patch) {
490
+ const current = state.lists[key] ?? EMPTY_LIST;
491
+ setState({ lists: { ...state.lists, [key]: { ...current, ...patch } } });
492
+ }
493
+ function setComments(featureId, patch) {
494
+ const current = state.comments[featureId] ?? EMPTY_COMMENTS;
495
+ setState({ comments: { ...state.comments, [featureId]: { ...current, ...patch } } });
496
+ }
497
+ function emit(event) {
498
+ onEvent?.(event);
499
+ }
500
+ function fail(error) {
501
+ emit({ name: "error", error });
502
+ }
503
+ function dedupe(key, run) {
504
+ const existing = inFlight.get(key);
505
+ if (existing) return existing;
506
+ const promise = run().finally(() => inFlight.delete(key));
507
+ inFlight.set(key, promise);
508
+ return promise;
509
+ }
510
+ function mapFeature(featureId, update) {
511
+ const lists = {};
512
+ let changed = false;
513
+ for (const [key, list] of Object.entries(state.lists)) {
514
+ const index = list.features.findIndex((feature) => feature.id === featureId);
515
+ if (index === -1) {
516
+ lists[key] = list;
517
+ continue;
518
+ }
519
+ changed = true;
520
+ const features = [...list.features];
521
+ features[index] = update(features[index]);
522
+ lists[key] = { ...list, features };
523
+ }
524
+ if (changed) setState({ lists });
525
+ }
526
+ async function fetchFeatures(query, append, size) {
527
+ const key = listKey(query);
528
+ const current = state.lists[key] ?? EMPTY_LIST;
529
+ if (append && !current.cursor) return;
530
+ setList(key, { status: append ? "loadingMore" : "loading", error: null });
531
+ const result = await client.listFeatures({
532
+ sort: query.sort,
533
+ status: query.status,
534
+ limit: size,
535
+ cursor: append ? current.cursor : null
536
+ });
537
+ if (!result.ok) {
538
+ setList(key, { status: "error", error: result.error });
539
+ fail(result.error);
540
+ return;
541
+ }
542
+ const previous = append ? state.lists[key]?.features ?? [] : [];
543
+ setList(key, {
544
+ features: dedupeById([...previous, ...result.data.features]),
545
+ cursor: result.data.nextCursor,
546
+ status: "ready",
547
+ error: null
548
+ });
549
+ }
550
+ async function fetchFeature(featureId) {
551
+ const key = featureKey(featureId);
552
+ setList(key, { status: "loading", error: null });
553
+ const result = await client.getFeature(featureId);
554
+ if (!result.ok) {
555
+ setList(key, { status: "error", error: result.error });
556
+ fail(result.error);
557
+ return;
558
+ }
559
+ setList(key, { features: [result.data], cursor: null, status: "ready", error: null });
560
+ }
561
+ async function fetchComments(featureId, append) {
562
+ const current = state.comments[featureId] ?? EMPTY_COMMENTS;
563
+ if (append && !current.cursor) return;
564
+ setComments(featureId, { status: append ? "loadingMore" : "loading", error: null });
565
+ let cursor = append ? current.cursor : null;
566
+ const collected = [];
567
+ for (let page = 0; page < MAX_EMPTY_PAGES; page += 1) {
568
+ const result = await client.listComments(featureId, { limit: pageSize, cursor });
569
+ if (!result.ok) {
570
+ setComments(featureId, { status: "error", error: result.error });
571
+ fail(result.error);
572
+ return;
573
+ }
574
+ collected.push(...result.data.comments);
575
+ cursor = result.data.nextCursor;
576
+ if (collected.length > 0 || !cursor) break;
577
+ }
578
+ const previous = append ? state.comments[featureId]?.comments ?? [] : [];
579
+ setComments(featureId, {
580
+ comments: dedupeById([...previous, ...collected]),
581
+ cursor,
582
+ status: "ready",
583
+ error: null
584
+ });
585
+ }
586
+ return {
587
+ subscribe(listener) {
588
+ listeners.add(listener);
589
+ return () => listeners.delete(listener);
590
+ },
591
+ getState: () => state,
592
+ emit,
593
+ ensureConfig() {
594
+ if (state.config) return;
595
+ void dedupe("config", async () => {
596
+ const result = await client.getConfig();
597
+ if (result.ok) setState({ config: result.data });
598
+ });
599
+ },
600
+ ensureFeatures(query, size = pageSize) {
601
+ const key = listKey(query);
602
+ if ((state.lists[key] ?? EMPTY_LIST).status !== "idle") return;
603
+ void dedupe(`features:${key}`, () => fetchFeatures(query, false, size));
604
+ },
605
+ refresh(query, size = pageSize) {
606
+ return dedupe(`features:${listKey(query)}`, () => fetchFeatures(query, false, size));
607
+ },
608
+ loadMore(query, size = pageSize) {
609
+ return dedupe(`features:more:${listKey(query)}`, () => fetchFeatures(query, true, size));
610
+ },
611
+ ensureFeature(featureId) {
612
+ if ((state.lists[featureKey(featureId)] ?? EMPTY_LIST).status !== "idle") return;
613
+ void dedupe(`feature:${featureId}`, () => fetchFeature(featureId));
614
+ },
615
+ refreshFeature(featureId) {
616
+ return dedupe(`feature:${featureId}`, () => fetchFeature(featureId));
617
+ },
618
+ ensureComments(featureId) {
619
+ if ((state.comments[featureId] ?? EMPTY_COMMENTS).status !== "idle") return;
620
+ void dedupe(`comments:${featureId}`, () => fetchComments(featureId, false));
621
+ },
622
+ refreshComments(featureId) {
623
+ return dedupe(`comments:${featureId}`, () => fetchComments(featureId, false));
624
+ },
625
+ loadMoreComments(featureId) {
626
+ return dedupe(`comments:more:${featureId}`, () => fetchComments(featureId, true));
627
+ },
628
+ async toggleVote(featureId) {
629
+ const before = findFeature(state, featureId);
630
+ if (!before)
631
+ return {
632
+ ok: false,
633
+ error: { code: "not_found", status: 404, message: "Unknown feature." }
634
+ };
635
+ const nextVoted = !before.hasVoted;
636
+ mapFeature(featureId, (feature) => ({
637
+ ...feature,
638
+ hasVoted: nextVoted,
639
+ upvoteCount: Math.max(0, feature.upvoteCount + (nextVoted ? 1 : -1))
640
+ }));
641
+ const result = nextVoted ? await client.vote(featureId) : await client.unvote(featureId);
642
+ if (!result.ok) {
643
+ mapFeature(featureId, (feature) => ({
644
+ ...feature,
645
+ hasVoted: before.hasVoted,
646
+ upvoteCount: before.upvoteCount
647
+ }));
648
+ fail(result.error);
649
+ return result;
650
+ }
651
+ mapFeature(featureId, (feature) => ({
652
+ ...feature,
653
+ hasVoted: result.data.hasVoted,
654
+ upvoteCount: result.data.upvoteCount
655
+ }));
656
+ emit({ name: "voted", featureId, hasVoted: result.data.hasVoted });
657
+ return result;
658
+ },
659
+ async submitFeature(input) {
660
+ const result = await client.submitFeature(input);
661
+ if (!result.ok) {
662
+ fail(result.error);
663
+ return result;
664
+ }
665
+ const lists = {};
666
+ for (const [key, list] of Object.entries(state.lists)) {
667
+ lists[key] = key.startsWith("new") ? { ...list, features: dedupeById([result.data, ...list.features]) } : list;
668
+ }
669
+ setState({ lists });
670
+ emit({ name: "featureSubmitted", featureId: result.data.id });
671
+ return result;
672
+ },
673
+ async addComment(input) {
674
+ const result = await client.createComment(input);
675
+ if (!result.ok) {
676
+ fail(result.error);
677
+ return result;
678
+ }
679
+ const current = state.comments[input.featureId] ?? EMPTY_COMMENTS;
680
+ setComments(input.featureId, { comments: [...current.comments, result.data] });
681
+ mapFeature(input.featureId, (feature) => ({
682
+ ...feature,
683
+ commentCount: feature.commentCount + 1
684
+ }));
685
+ emit({ name: "commented", featureId: input.featureId });
686
+ return result;
687
+ },
688
+ async setFollowing(featureId, email) {
689
+ const result = email ? await client.follow(featureId, email) : await client.unfollow(featureId);
690
+ if (!result.ok) {
691
+ fail(result.error);
692
+ return result;
693
+ }
694
+ mapFeature(featureId, (feature) => ({
695
+ ...feature,
696
+ isFollowing: result.data.following,
697
+ followerCount: Math.max(0, feature.followerCount + (result.data.following ? 1 : -1))
698
+ }));
699
+ emit({ name: "followed", featureId, following: result.data.following });
700
+ return result;
701
+ }
702
+ };
703
+ }
704
+ function listStateOf(state, key) {
705
+ return state.lists[key] ?? EMPTY_LIST;
706
+ }
707
+ function commentsStateOf(state, featureId) {
708
+ return state.comments[featureId] ?? EMPTY_COMMENTS;
709
+ }
710
+ function findFeature(state, featureId) {
711
+ for (const list of Object.values(state.lists)) {
712
+ const found = list.features.find((feature) => feature.id === featureId);
713
+ if (found) return found;
714
+ }
715
+ return null;
716
+ }
717
+ function dedupeById(items) {
718
+ const seen = /* @__PURE__ */ new Set();
719
+ const result = [];
720
+ for (const item of items) {
721
+ if (seen.has(item.id)) continue;
722
+ seen.add(item.id);
723
+ result.push(item);
724
+ }
725
+ return result;
726
+ }
727
+
728
+ // src/context/provider.tsx
729
+ var import_jsx_runtime = require("react/jsx-runtime");
730
+ var FewchursContext = (0, import_react.createContext)(null);
731
+ function FewchursProvider({
732
+ apiKey,
733
+ baseUrl,
734
+ deviceId: deviceIdProp,
735
+ language,
736
+ pageSize = 20,
737
+ initialState,
738
+ onEvent,
739
+ client: clientProp,
740
+ children
741
+ }) {
742
+ const [deviceId, setDeviceId] = (0, import_react.useState)(() => deviceIdProp ?? readDeviceId());
743
+ const deviceIdRef = (0, import_react.useRef)(deviceId);
744
+ deviceIdRef.current = deviceIdProp ?? deviceId;
745
+ const eventRef = (0, import_react.useRef)(onEvent);
746
+ eventRef.current = onEvent;
747
+ (0, import_react.useEffect)(() => {
748
+ if (deviceIdProp) return;
749
+ setDeviceId(resolveDeviceId());
750
+ }, [deviceIdProp]);
751
+ const client = (0, import_react.useMemo)(
752
+ () => clientProp ?? createFewchursClient({
753
+ apiKey,
754
+ baseUrl,
755
+ language,
756
+ deviceId: () => deviceIdRef.current
757
+ }),
758
+ [clientProp, apiKey, baseUrl, language]
759
+ );
760
+ const store = (0, import_react.useMemo)(
761
+ () => createStore(client, {
762
+ pageSize,
763
+ initialState,
764
+ onEvent: (event) => eventRef.current?.(event)
765
+ }),
766
+ // initialState is a first-paint seed; a new object identity must not wipe live state.
767
+ // eslint-disable-next-line react-hooks/exhaustive-deps
768
+ [client, pageSize]
769
+ );
770
+ const value = (0, import_react.useMemo)(
771
+ () => ({
772
+ client,
773
+ store,
774
+ deviceId: deviceIdProp ?? deviceId,
775
+ isReady: Boolean(deviceIdProp ?? deviceId) || !apiKey
776
+ }),
777
+ [client, store, deviceIdProp, deviceId, apiKey]
778
+ );
779
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FewchursContext.Provider, { value, children });
780
+ }
781
+ function useFewchurs() {
782
+ const value = (0, import_react.useContext)(FewchursContext);
783
+ if (!value) {
784
+ throw new Error(
785
+ "Fewchurs components must be rendered inside <FewchursProvider>. In Next.js, <FewchursBoard> from @fewchurs/next provides one for you."
786
+ );
787
+ }
788
+ return value;
789
+ }
790
+
791
+ // src/components/Board.tsx
792
+ var import_react15 = require("react");
793
+
794
+ // src/hooks/useFewchursConfig.ts
795
+ var import_react3 = require("react");
796
+
797
+ // src/hooks/useStoreState.ts
798
+ var import_react2 = require("react");
799
+ function useStoreState() {
800
+ const { store } = useFewchurs();
801
+ return (0, import_react2.useSyncExternalStore)(store.subscribe, store.getState, store.getState);
802
+ }
803
+
804
+ // src/hooks/useFewchursConfig.ts
805
+ function useFewchursConfig() {
806
+ const { store } = useFewchurs();
807
+ const state = useStoreState();
808
+ (0, import_react3.useEffect)(() => {
809
+ store.ensureConfig();
810
+ }, [store]);
811
+ return {
812
+ showBranding: state.config?.showBranding ?? false,
813
+ hideVoteCounts: state.config?.hideVoteCounts ?? false,
814
+ appName: state.config?.appName ?? null,
815
+ isLoading: state.config === null
816
+ };
817
+ }
818
+
819
+ // src/components/BoardContext.tsx
820
+ var import_react4 = require("react");
821
+
822
+ // src/components/labels.ts
823
+ var DEFAULT_LABELS = {
824
+ title: "Feature requests",
825
+ tabTop: "Top",
826
+ tabNew: "New",
827
+ tabRoadmap: "Roadmap",
828
+ newRequest: "New request",
829
+ cancel: "Cancel",
830
+ submit: "Submit request",
831
+ submitting: "Submitting\u2026",
832
+ submitted: "Thanks \u2014 your request is in.",
833
+ fieldTitle: "Title",
834
+ fieldTitlePlaceholder: "Add dark mode",
835
+ fieldDescription: "Details",
836
+ fieldDescriptionPlaceholder: "What would this let you do?",
837
+ fieldEmail: "Email",
838
+ fieldEmailHint: "Only used to tell you when this ships.",
839
+ fieldName: "Your name",
840
+ emptyTitle: "No feature requests yet",
841
+ emptyBody: "Be the first to suggest something.",
842
+ loading: "Loading\u2026",
843
+ loadMore: "Load more",
844
+ retry: "Try again",
845
+ comments: "Comments",
846
+ commentPlaceholder: "Add a comment",
847
+ postComment: "Post",
848
+ noComments: "No comments yet.",
849
+ follow: "Notify me",
850
+ following: "You'll be notified",
851
+ followPlaceholder: "you@example.com",
852
+ developer: "Team",
853
+ poweredBy: "Powered by Fewchurs",
854
+ upvote: "Upvote",
855
+ status: {
856
+ open: "Open",
857
+ planned: "Planned",
858
+ in_progress: "In progress",
859
+ done: "Done",
860
+ declined: "Declined",
861
+ unknown: "Open"
862
+ }
863
+ };
864
+ function mergeLabels(overrides) {
865
+ if (!overrides) return DEFAULT_LABELS;
866
+ return {
867
+ ...DEFAULT_LABELS,
868
+ ...overrides,
869
+ status: { ...DEFAULT_LABELS.status, ...overrides.status }
870
+ };
871
+ }
872
+
873
+ // src/components/BoardContext.tsx
874
+ var BoardContext = (0, import_react4.createContext)(null);
875
+ var BoardContextProvider = BoardContext.Provider;
876
+ function useBoard() {
877
+ const value = (0, import_react4.useContext)(BoardContext);
878
+ if (!value) {
879
+ throw new Error("Fewchurs board parts must be rendered inside <FewchursBoard.Root>.");
880
+ }
881
+ return value;
882
+ }
883
+ function makeClassName(unstyled) {
884
+ return (...names) => unstyled ? void 0 : names.filter(Boolean).join(" ");
885
+ }
886
+
887
+ // src/components/Branding.tsx
888
+ var import_jsx_runtime2 = require("react/jsx-runtime");
889
+ function Branding() {
890
+ const { labels, cx } = useBoard();
891
+ const { showBranding } = useFewchursConfig();
892
+ if (!showBranding) return null;
893
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: cx("fw-branding"), children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
894
+ "a",
895
+ {
896
+ className: cx("fw-link"),
897
+ href: "https://www.fewchurs.shop",
898
+ target: "_blank",
899
+ rel: "noreferrer noopener",
900
+ children: labels.poweredBy
901
+ }
902
+ ) });
903
+ }
904
+
905
+ // src/hooks/useFeature.ts
906
+ var import_react5 = require("react");
907
+ function useFeature(featureId, { enabled = true } = {}) {
908
+ const { store, isReady } = useFewchurs();
909
+ const state = useStoreState();
910
+ const active = Boolean(featureId) && enabled;
911
+ const list = listStateOf(state, featureKey(featureId ?? ""));
912
+ (0, import_react5.useEffect)(() => {
913
+ if (!active || !isReady || !featureId) return;
914
+ store.ensureFeature(featureId);
915
+ }, [store, featureId, active, isReady]);
916
+ const refresh = (0, import_react5.useCallback)(
917
+ () => featureId ? store.refreshFeature(featureId) : Promise.resolve(),
918
+ [store, featureId]
919
+ );
920
+ return {
921
+ feature: list.features[0] ?? null,
922
+ isLoading: active && (list.status === "loading" || list.status === "idle"),
923
+ error: list.error,
924
+ refresh
925
+ };
926
+ }
927
+
928
+ // src/hooks/useFeatures.ts
929
+ var import_react6 = require("react");
930
+ function useFeatures({
931
+ sort = "new",
932
+ status,
933
+ pageSize,
934
+ enabled = true
935
+ } = {}) {
936
+ const { store, isReady } = useFewchurs();
937
+ const state = useStoreState();
938
+ const statusKey = status?.length ? [...status].sort().join(",") : "";
939
+ const query = (0, import_react6.useMemo)(
940
+ () => ({ sort, status: statusKey ? statusKey.split(",") : void 0 }),
941
+ [sort, statusKey]
942
+ );
943
+ const key = listKey(query);
944
+ const list = listStateOf(state, key);
945
+ (0, import_react6.useEffect)(() => {
946
+ if (!enabled || !isReady) return;
947
+ store.ensureFeatures(query, pageSize);
948
+ }, [store, query, pageSize, enabled, isReady]);
949
+ const loadMore = (0, import_react6.useCallback)(() => store.loadMore(query, pageSize), [store, query, pageSize]);
950
+ const refresh = (0, import_react6.useCallback)(() => store.refresh(query, pageSize), [store, query, pageSize]);
951
+ return {
952
+ features: list.features,
953
+ isLoading: list.status === "loading" || list.status === "idle" && enabled,
954
+ isLoadingMore: list.status === "loadingMore",
955
+ error: list.error,
956
+ hasMore: list.cursor !== null,
957
+ loadMore,
958
+ refresh
959
+ };
960
+ }
961
+
962
+ // src/hooks/useFollow.ts
963
+ var import_react7 = require("react");
964
+ function useFollow(featureId) {
965
+ const { store } = useFewchurs();
966
+ const state = useStoreState();
967
+ const [isPending, setIsPending] = (0, import_react7.useState)(false);
968
+ const [error, setError] = (0, import_react7.useState)(null);
969
+ const feature = Object.values(state.lists).flatMap((list) => list.features).find((candidate) => candidate.id === featureId);
970
+ const run = (0, import_react7.useCallback)(
971
+ async (email) => {
972
+ setIsPending(true);
973
+ setError(null);
974
+ const result = await store.setFollowing(featureId, email);
975
+ if (!result.ok) setError(result.error);
976
+ setIsPending(false);
977
+ return result.ok;
978
+ },
979
+ [store, featureId]
980
+ );
981
+ return {
982
+ isFollowing: feature?.isFollowing ?? false,
983
+ isPending,
984
+ error,
985
+ follow: (0, import_react7.useCallback)((email) => run(email), [run]),
986
+ unfollow: (0, import_react7.useCallback)(() => run(null), [run])
987
+ };
988
+ }
989
+
990
+ // src/hooks/useVote.ts
991
+ var import_react8 = require("react");
992
+ function useVote(featureId) {
993
+ const { store } = useFewchurs();
994
+ const state = useStoreState();
995
+ const [isPending, setIsPending] = (0, import_react8.useState)(false);
996
+ const [error, setError] = (0, import_react8.useState)(null);
997
+ const feature = Object.values(state.lists).flatMap((list) => list.features).find((candidate) => candidate.id === featureId);
998
+ const toggle = (0, import_react8.useCallback)(async () => {
999
+ setIsPending(true);
1000
+ setError(null);
1001
+ const result = await store.toggleVote(featureId);
1002
+ if (!result.ok) setError(result.error);
1003
+ setIsPending(false);
1004
+ }, [store, featureId]);
1005
+ const vote = (0, import_react8.useCallback)(async () => {
1006
+ if (feature?.hasVoted) return;
1007
+ await toggle();
1008
+ }, [feature?.hasVoted, toggle]);
1009
+ const unvote = (0, import_react8.useCallback)(async () => {
1010
+ if (!feature?.hasVoted) return;
1011
+ await toggle();
1012
+ }, [feature?.hasVoted, toggle]);
1013
+ return {
1014
+ hasVoted: feature?.hasVoted ?? false,
1015
+ upvoteCount: feature?.upvoteCount ?? 0,
1016
+ isPending,
1017
+ error,
1018
+ toggle,
1019
+ vote,
1020
+ unvote
1021
+ };
1022
+ }
1023
+
1024
+ // src/components/FeatureCard.tsx
1025
+ var import_react12 = require("react");
1026
+
1027
+ // src/components/Comments.tsx
1028
+ var import_react10 = require("react");
1029
+
1030
+ // src/hooks/useComments.ts
1031
+ var import_react9 = require("react");
1032
+ function useComments(featureId, { enabled = true } = {}) {
1033
+ const { store, isReady } = useFewchurs();
1034
+ const state = useStoreState();
1035
+ const [isSubmitting, setIsSubmitting] = (0, import_react9.useState)(false);
1036
+ const [submitError, setSubmitError] = (0, import_react9.useState)(null);
1037
+ const comments = commentsStateOf(state, featureId);
1038
+ (0, import_react9.useEffect)(() => {
1039
+ if (!enabled || !isReady) return;
1040
+ store.ensureComments(featureId);
1041
+ }, [store, featureId, enabled, isReady]);
1042
+ const loadMore = (0, import_react9.useCallback)(() => store.loadMoreComments(featureId), [store, featureId]);
1043
+ const add = (0, import_react9.useCallback)(
1044
+ async (text, authorName) => {
1045
+ setIsSubmitting(true);
1046
+ setSubmitError(null);
1047
+ const result = await store.addComment({ featureId, text, authorName });
1048
+ if (!result.ok) setSubmitError(result.error);
1049
+ setIsSubmitting(false);
1050
+ return result.ok;
1051
+ },
1052
+ [store, featureId]
1053
+ );
1054
+ return {
1055
+ comments: comments.comments,
1056
+ isLoading: comments.status === "loading" || comments.status === "idle" && enabled,
1057
+ isLoadingMore: comments.status === "loadingMore",
1058
+ isSubmitting,
1059
+ error: comments.error,
1060
+ submitError,
1061
+ hasMore: comments.cursor !== null,
1062
+ loadMore,
1063
+ add
1064
+ };
1065
+ }
1066
+
1067
+ // src/components/time.ts
1068
+ var FORMATTER = new Intl.DateTimeFormat("en", {
1069
+ month: "short",
1070
+ day: "numeric",
1071
+ timeZone: "UTC"
1072
+ });
1073
+ function formatDate(iso) {
1074
+ const date = new Date(iso);
1075
+ return Number.isNaN(date.getTime()) ? "" : FORMATTER.format(date);
1076
+ }
1077
+
1078
+ // src/components/Comments.tsx
1079
+ var import_jsx_runtime3 = require("react/jsx-runtime");
1080
+ function CommentList({ featureId }) {
1081
+ const { labels, cx } = useBoard();
1082
+ const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
1083
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-comments"), children: [
1084
+ isLoading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.loading }),
1085
+ error && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1086
+ !isLoading && !error && comments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.noComments }),
1087
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("li", { className: cx("fw-comment"), children: [
1088
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("fw-comment__author"), children: comment.authorName }),
1089
+ comment.isDeveloper && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: cx("fw-badge"), children: [
1090
+ " ",
1091
+ labels.developer
1092
+ ] }),
1093
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: cx("fw-muted"), children: [
1094
+ " \xB7 ",
1095
+ formatDate(comment.createdAt)
1096
+ ] }),
1097
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-comment__text"), children: comment.text })
1098
+ ] }, comment.id)) }),
1099
+ hasMore && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1100
+ "button",
1101
+ {
1102
+ type: "button",
1103
+ className: cx("fw-btn", "fw-btn--ghost"),
1104
+ disabled: isLoadingMore,
1105
+ onClick: () => void loadMore(),
1106
+ children: isLoadingMore ? labels.loading : labels.loadMore
1107
+ }
1108
+ ),
1109
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(CommentForm, { featureId })
1110
+ ] });
1111
+ }
1112
+ function CommentForm({ featureId }) {
1113
+ const { labels, cx } = useBoard();
1114
+ const { add, isSubmitting, submitError } = useComments(featureId);
1115
+ const [text, setText] = (0, import_react10.useState)("");
1116
+ const [authorName, setAuthorName] = (0, import_react10.useState)("");
1117
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1118
+ "form",
1119
+ {
1120
+ className: cx("fw-form", "fw-form--inline"),
1121
+ onSubmit: async (event) => {
1122
+ event.preventDefault();
1123
+ const posted = await add(text, authorName);
1124
+ if (posted) setText("");
1125
+ },
1126
+ children: [
1127
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-field"), children: [
1128
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { className: cx("fw-sr-only"), htmlFor: `fw-comment-${featureId}`, children: labels.commentPlaceholder }),
1129
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1130
+ "input",
1131
+ {
1132
+ id: `fw-comment-${featureId}`,
1133
+ className: cx("fw-input"),
1134
+ value: text,
1135
+ maxLength: LIMITS.commentMax,
1136
+ placeholder: labels.commentPlaceholder,
1137
+ onChange: (event) => setText(event.target.value),
1138
+ required: true
1139
+ }
1140
+ )
1141
+ ] }),
1142
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-field"), children: [
1143
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("label", { className: cx("fw-sr-only"), htmlFor: `fw-comment-name-${featureId}`, children: labels.fieldName }),
1144
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1145
+ "input",
1146
+ {
1147
+ id: `fw-comment-name-${featureId}`,
1148
+ className: cx("fw-input"),
1149
+ value: authorName,
1150
+ maxLength: LIMITS.authorNameMax,
1151
+ placeholder: labels.fieldName,
1152
+ onChange: (event) => setAuthorName(event.target.value)
1153
+ }
1154
+ )
1155
+ ] }),
1156
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
1157
+ submitError && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
1158
+ ]
1159
+ }
1160
+ );
1161
+ }
1162
+
1163
+ // src/components/FollowForm.tsx
1164
+ var import_react11 = require("react");
1165
+ var import_jsx_runtime4 = require("react/jsx-runtime");
1166
+ function FollowForm({ feature }) {
1167
+ const { labels, userEmail, cx } = useBoard();
1168
+ const { isFollowing, isPending, error, follow, unfollow } = useFollow(feature.id);
1169
+ const [email, setEmail] = (0, import_react11.useState)(userEmail ?? "");
1170
+ const [isOpen, setIsOpen] = (0, import_react11.useState)(false);
1171
+ if (isFollowing) {
1172
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1173
+ "button",
1174
+ {
1175
+ type: "button",
1176
+ className: cx("fw-link"),
1177
+ disabled: isPending,
1178
+ onClick: () => void unfollow(),
1179
+ children: labels.following
1180
+ }
1181
+ );
1182
+ }
1183
+ if (!isOpen) {
1184
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
1185
+ }
1186
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1187
+ "form",
1188
+ {
1189
+ className: cx("fw-form", "fw-form--inline"),
1190
+ onSubmit: async (event) => {
1191
+ event.preventDefault();
1192
+ const ok = await follow(email);
1193
+ if (ok) setIsOpen(false);
1194
+ },
1195
+ children: [
1196
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("label", { className: cx("fw-sr-only"), htmlFor: `fw-follow-${feature.id}`, children: labels.fieldEmail }),
1197
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1198
+ "input",
1199
+ {
1200
+ id: `fw-follow-${feature.id}`,
1201
+ className: cx("fw-input"),
1202
+ type: "email",
1203
+ value: email,
1204
+ placeholder: labels.followPlaceholder,
1205
+ onChange: (event) => setEmail(event.target.value),
1206
+ required: true
1207
+ }
1208
+ ),
1209
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: labels.follow }),
1210
+ error && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1211
+ ]
1212
+ }
1213
+ );
1214
+ }
1215
+
1216
+ // src/components/StatusBadge.tsx
1217
+ var import_jsx_runtime5 = require("react/jsx-runtime");
1218
+ function StatusBadge({ status }) {
1219
+ const { labels, cx } = useBoard();
1220
+ if (status === "open" || status === "unknown") return null;
1221
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: cx("fw-status", `fw-status--${status}`), children: labels.status[status] });
1222
+ }
1223
+
1224
+ // src/components/VoteButton.tsx
1225
+ var import_jsx_runtime6 = require("react/jsx-runtime");
1226
+ function VoteButton({ feature, isPending, onToggle }) {
1227
+ const { labels, hideVoteCounts, cx } = useBoard();
1228
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
1229
+ "button",
1230
+ {
1231
+ type: "button",
1232
+ className: cx("fw-vote"),
1233
+ "aria-pressed": feature.hasVoted,
1234
+ "aria-label": `${labels.upvote} ${feature.title}`,
1235
+ disabled: isPending,
1236
+ onClick: onToggle,
1237
+ children: [
1238
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: cx("fw-vote__arrow"), "aria-hidden": "true", children: "\u25B2" }),
1239
+ !hideVoteCounts && /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: cx("fw-vote__count"), children: feature.upvoteCount })
1240
+ ]
1241
+ }
1242
+ );
1243
+ }
1244
+
1245
+ // src/components/FeatureCard.tsx
1246
+ var import_jsx_runtime7 = require("react/jsx-runtime");
1247
+ function FeatureCard({
1248
+ feature,
1249
+ defaultCommentsOpen = false
1250
+ }) {
1251
+ const { labels, allowComments, cx } = useBoard();
1252
+ const { isPending, toggle } = useVote(feature.id);
1253
+ const [showComments, setShowComments] = (0, import_react12.useState)(defaultCommentsOpen);
1254
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("li", { className: cx("fw-card"), children: [
1255
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
1256
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: cx("fw-card__body"), children: [
1257
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("h3", { className: cx("fw-card__title"), children: feature.title }),
1258
+ feature.description && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: cx("fw-card__desc"), children: feature.description }),
1259
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: cx("fw-meta"), children: [
1260
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatusBadge, { status: feature.status }),
1261
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { children: formatDate(feature.createdAt) }),
1262
+ allowComments && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
1263
+ "button",
1264
+ {
1265
+ type: "button",
1266
+ className: cx("fw-link"),
1267
+ "aria-expanded": showComments,
1268
+ "aria-controls": `fw-comments-${feature.id}`,
1269
+ onClick: () => setShowComments((open) => !open),
1270
+ children: [
1271
+ labels.comments,
1272
+ " (",
1273
+ feature.commentCount,
1274
+ ")"
1275
+ ]
1276
+ }
1277
+ ),
1278
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FollowForm, { feature })
1279
+ ] }),
1280
+ allowComments && showComments && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(CommentList, { featureId: feature.id }) })
1281
+ ] })
1282
+ ] });
1283
+ }
1284
+
1285
+ // src/components/Skeleton.tsx
1286
+ var import_jsx_runtime8 = require("react/jsx-runtime");
1287
+ var ROW_COUNT = 3;
1288
+ var ROADMAP_ROW_COUNT = 2;
1289
+ function FeatureListSkeleton({
1290
+ roadmap = false,
1291
+ rows = ROW_COUNT
1292
+ }) {
1293
+ const { labels, cx } = useBoard();
1294
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-skeleton"), role: "status", "aria-busy": "true", children: [
1295
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: cx("fw-sr-only"), children: labels.loading }),
1296
+ roadmap ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-roadmap"), "aria-hidden": "true", children: ROADMAP_STATUSES.map((status) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-roadmap__column"), children: [
1297
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "40%" }),
1298
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Rows, { count: ROADMAP_ROW_COUNT })
1299
+ ] }, status)) }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Rows, { count: rows }) })
1300
+ ] });
1301
+ }
1302
+ function Rows({ count }) {
1303
+ const { cx } = useBoard();
1304
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-list"), children: Array.from({ length: count }, (_, index) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-card", "fw-skeleton__card"), children: [
1305
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-skeleton__vote") }),
1306
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: cx("fw-card__body"), children: [
1307
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "62%" }),
1308
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "88%", height: "0.6rem" }),
1309
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Bar, { width: "34%", height: "0.6rem" })
1310
+ ] })
1311
+ ] }, index)) });
1312
+ }
1313
+ function Bar({ width, height }) {
1314
+ const { cx } = useBoard();
1315
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: cx("fw-skeleton__bar"), style: { width, height } });
1316
+ }
1317
+
1318
+ // src/components/FeatureList.tsx
1319
+ var import_jsx_runtime9 = require("react/jsx-runtime");
1320
+ function FeatureList() {
1321
+ const { initialFeatureId } = useBoard();
1322
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
1323
+ initialFeatureId && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(PinnedFeature, { featureId: initialFeatureId }),
1324
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Listing, { excludeId: initialFeatureId })
1325
+ ] });
1326
+ }
1327
+ function PinnedFeature({ featureId }) {
1328
+ const { cx } = useBoard();
1329
+ const { feature, isLoading } = useFeature(featureId);
1330
+ if (isLoading) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureListSkeleton, { rows: 1 });
1331
+ if (!feature) return null;
1332
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
1333
+ }
1334
+ function Listing({ excludeId }) {
1335
+ const { tab, showDeclined, labels, cx, renderEmpty, pageSize } = useBoard();
1336
+ const sort = tab === "roadmap" ? "top" : tab;
1337
+ const isRoadmap = tab === "roadmap";
1338
+ const { features, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useFeatures({
1339
+ sort,
1340
+ status: isRoadmap ? ROADMAP_STATUSES : void 0,
1341
+ pageSize
1342
+ });
1343
+ const visible = features.filter((feature) => {
1344
+ if (feature.id === excludeId) return false;
1345
+ if (isRoadmap) return ROADMAP_STATUSES.includes(feature.status);
1346
+ return showDeclined || feature.status !== "declined";
1347
+ });
1348
+ if (error && visible.length === 0) {
1349
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-empty"), role: "alert", children: [
1350
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { children: userMessage(error) }),
1351
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1352
+ "button",
1353
+ {
1354
+ type: "button",
1355
+ className: cx("fw-btn", "fw-btn--ghost"),
1356
+ onClick: () => void refresh(),
1357
+ children: labels.retry
1358
+ }
1359
+ )
1360
+ ] });
1361
+ }
1362
+ if (isLoading && visible.length === 0) {
1363
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureListSkeleton, { roadmap: isRoadmap });
1364
+ }
1365
+ if (visible.length === 0) {
1366
+ if (renderEmpty) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_jsx_runtime9.Fragment, { children: renderEmpty() });
1367
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-empty"), children: [
1368
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-empty__title"), children: labels.emptyTitle }),
1369
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { children: labels.emptyBody })
1370
+ ] });
1371
+ }
1372
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
1373
+ isRoadmap ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(RoadmapColumns, { features: visible }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Rows2, { features: visible }),
1374
+ hasMore && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1375
+ "button",
1376
+ {
1377
+ type: "button",
1378
+ className: cx("fw-btn", "fw-btn--ghost"),
1379
+ disabled: isLoadingMore,
1380
+ onClick: () => void loadMore(),
1381
+ children: isLoadingMore ? labels.loading : labels.loadMore
1382
+ }
1383
+ ) })
1384
+ ] });
1385
+ }
1386
+ function Rows2({ features }) {
1387
+ const { cx } = useBoard();
1388
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: features.map((feature) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureRow, { feature }, feature.id)) });
1389
+ }
1390
+ function RoadmapColumns({ features }) {
1391
+ const { labels, cx } = useBoard();
1392
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-roadmap"), children: ROADMAP_STATUSES.map((status) => {
1393
+ const column = features.filter((feature) => feature.status === status);
1394
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
1395
+ "section",
1396
+ {
1397
+ className: cx("fw-roadmap__column"),
1398
+ "aria-label": labels.status[status],
1399
+ children: [
1400
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("h3", { className: cx("fw-roadmap__heading"), children: labels.status[status] }),
1401
+ column.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-muted"), children: "\u2014" }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: column.map((feature) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureRow, { feature }, feature.id)) })
1402
+ ]
1403
+ },
1404
+ status
1405
+ );
1406
+ }) });
1407
+ }
1408
+ function FeatureRow({ feature }) {
1409
+ const { renderFeature } = useBoard();
1410
+ if (!renderFeature) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureCard, { feature });
1411
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureSlot, { feature });
1412
+ }
1413
+ function FeatureSlot({ feature }) {
1414
+ const { renderFeature } = useBoard();
1415
+ const { toggle } = useVote(feature.id);
1416
+ const { follow, unfollow } = useFollow(feature.id);
1417
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_jsx_runtime9.Fragment, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1418
+ }
1419
+
1420
+ // src/components/SubmitForm.tsx
1421
+ var import_react14 = require("react");
1422
+
1423
+ // src/hooks/useSubmitFeature.ts
1424
+ var import_react13 = require("react");
1425
+ function useSubmitFeature() {
1426
+ const { store } = useFewchurs();
1427
+ const [isPending, setIsPending] = (0, import_react13.useState)(false);
1428
+ const [error, setError] = (0, import_react13.useState)(null);
1429
+ const submit = (0, import_react13.useCallback)(
1430
+ async (input) => {
1431
+ setIsPending(true);
1432
+ setError(null);
1433
+ const result = await store.submitFeature(input);
1434
+ setIsPending(false);
1435
+ if (!result.ok) {
1436
+ setError(result.error);
1437
+ return null;
1438
+ }
1439
+ return result.data;
1440
+ },
1441
+ [store]
1442
+ );
1443
+ const reset = (0, import_react13.useCallback)(() => setError(null), []);
1444
+ return { submit, isPending, error, reset };
1445
+ }
1446
+
1447
+ // src/components/SubmitForm.tsx
1448
+ var import_jsx_runtime10 = require("react/jsx-runtime");
1449
+ function SubmitForm() {
1450
+ const { labels, emailField, userEmail, isSubscriber, setFormOpen, setJustSubmitted, cx } = useBoard();
1451
+ const { submit, isPending, error } = useSubmitFeature();
1452
+ const [title, setTitle] = (0, import_react14.useState)("");
1453
+ const [description, setDescription] = (0, import_react14.useState)("");
1454
+ const [email, setEmail] = (0, import_react14.useState)(userEmail ?? "");
1455
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1456
+ "form",
1457
+ {
1458
+ className: cx("fw-form"),
1459
+ onSubmit: async (event) => {
1460
+ event.preventDefault();
1461
+ const created = await submit({
1462
+ title,
1463
+ description,
1464
+ ...emailField !== "off" && email ? { email } : {},
1465
+ isSubscriber
1466
+ });
1467
+ if (!created) return;
1468
+ setTitle("");
1469
+ setDescription("");
1470
+ setJustSubmitted(true);
1471
+ setFormOpen(false);
1472
+ },
1473
+ children: [
1474
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1475
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-title", children: labels.fieldTitle }),
1476
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1477
+ "input",
1478
+ {
1479
+ id: "fw-title",
1480
+ className: cx("fw-input"),
1481
+ value: title,
1482
+ minLength: LIMITS.titleMin,
1483
+ maxLength: LIMITS.titleMax,
1484
+ placeholder: labels.fieldTitlePlaceholder,
1485
+ onChange: (event) => setTitle(event.target.value),
1486
+ required: true
1487
+ }
1488
+ )
1489
+ ] }),
1490
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1491
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
1492
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1493
+ "textarea",
1494
+ {
1495
+ id: "fw-description",
1496
+ className: cx("fw-input", "fw-textarea"),
1497
+ value: description,
1498
+ maxLength: LIMITS.descriptionMax,
1499
+ placeholder: labels.fieldDescriptionPlaceholder,
1500
+ onChange: (event) => setDescription(event.target.value)
1501
+ }
1502
+ )
1503
+ ] }),
1504
+ emailField !== "off" && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1505
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
1506
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1507
+ "input",
1508
+ {
1509
+ id: "fw-email",
1510
+ className: cx("fw-input"),
1511
+ type: "email",
1512
+ value: email,
1513
+ placeholder: labels.followPlaceholder,
1514
+ "aria-describedby": "fw-email-hint",
1515
+ onChange: (event) => setEmail(event.target.value),
1516
+ required: emailField === "required"
1517
+ }
1518
+ ),
1519
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1520
+ ] }),
1521
+ error && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1522
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-form__actions"), children: [
1523
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: isPending ? labels.submitting : labels.submit }),
1524
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1525
+ "button",
1526
+ {
1527
+ type: "button",
1528
+ className: cx("fw-btn", "fw-btn--ghost"),
1529
+ onClick: () => setFormOpen(false),
1530
+ children: labels.cancel
1531
+ }
1532
+ )
1533
+ ] })
1534
+ ]
1535
+ }
1536
+ );
1537
+ }
1538
+
1539
+ // src/components/theme.ts
1540
+ var VARIABLE_BY_KEY = {
1541
+ primary: "--fw-primary",
1542
+ radius: "--fw-radius",
1543
+ surface: "--fw-surface",
1544
+ border: "--fw-border",
1545
+ text: "--fw-text",
1546
+ subtle: "--fw-subtle",
1547
+ font: "--fw-font"
1548
+ };
1549
+ function themeToStyle(theme) {
1550
+ if (!theme) return void 0;
1551
+ const style = {};
1552
+ for (const [key, value] of Object.entries(theme)) {
1553
+ if (value === void 0 || value === null) continue;
1554
+ const variable = VARIABLE_BY_KEY[key];
1555
+ style[variable] = typeof value === "number" ? `${value}px` : value;
1556
+ }
1557
+ return style;
1558
+ }
1559
+
1560
+ // src/components/Board.tsx
1561
+ var import_jsx_runtime11 = require("react/jsx-runtime");
1562
+ var DEFAULT_TABS = ["top", "new"];
1563
+ function BoardRoot({
1564
+ tabs = DEFAULT_TABS,
1565
+ defaultTab,
1566
+ initialFeatureId,
1567
+ showDeclined = false,
1568
+ allowComments = true,
1569
+ emailField = "optional",
1570
+ pageSize,
1571
+ labels,
1572
+ theme,
1573
+ unstyled = false,
1574
+ colorScheme = "system",
1575
+ className,
1576
+ style,
1577
+ userEmail,
1578
+ isSubscriber = false,
1579
+ renderHeader,
1580
+ renderEmpty,
1581
+ renderFeature,
1582
+ children
1583
+ }) {
1584
+ const { store } = useFewchurs();
1585
+ const { hideVoteCounts } = useFewchursConfig();
1586
+ const [tab, setTab] = (0, import_react15.useState)(defaultTab ?? tabs[0] ?? "top");
1587
+ const [isFormOpen, setFormOpen] = (0, import_react15.useState)(false);
1588
+ const [justSubmitted, setJustSubmitted] = (0, import_react15.useState)(false);
1589
+ (0, import_react15.useEffect)(() => {
1590
+ store.emit({ name: "boardOpened" });
1591
+ }, [store]);
1592
+ const value = (0, import_react15.useMemo)(
1593
+ () => ({
1594
+ labels: mergeLabels(labels),
1595
+ unstyled,
1596
+ allowComments,
1597
+ emailField,
1598
+ showDeclined,
1599
+ hideVoteCounts,
1600
+ pageSize,
1601
+ userEmail,
1602
+ isSubscriber,
1603
+ tab,
1604
+ setTab,
1605
+ tabs,
1606
+ initialFeatureId,
1607
+ isFormOpen,
1608
+ setFormOpen,
1609
+ justSubmitted,
1610
+ setJustSubmitted,
1611
+ renderFeature,
1612
+ renderEmpty,
1613
+ renderHeader,
1614
+ cx: makeClassName(unstyled)
1615
+ }),
1616
+ [
1617
+ labels,
1618
+ unstyled,
1619
+ allowComments,
1620
+ emailField,
1621
+ showDeclined,
1622
+ hideVoteCounts,
1623
+ pageSize,
1624
+ userEmail,
1625
+ isSubscriber,
1626
+ tab,
1627
+ tabs,
1628
+ initialFeatureId,
1629
+ isFormOpen,
1630
+ justSubmitted,
1631
+ renderFeature,
1632
+ renderEmpty,
1633
+ renderHeader
1634
+ ]
1635
+ );
1636
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardContextProvider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1637
+ "section",
1638
+ {
1639
+ className: [unstyled ? "" : "fewchurs", className].filter(Boolean).join(" ") || void 0,
1640
+ style: { ...themeToStyle(theme), ...style },
1641
+ "data-theme": colorScheme === "system" ? void 0 : colorScheme,
1642
+ children: children ?? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DefaultBoard, {})
1643
+ }
1644
+ ) });
1645
+ }
1646
+ function DefaultBoard() {
1647
+ const { isFormOpen, justSubmitted, labels, renderHeader, cx } = useBoard();
1648
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_jsx_runtime11.Fragment, { children: [
1649
+ renderHeader ? renderHeader() : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardHeading, {}),
1650
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: cx("fw-toolbar"), children: [
1651
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardTabs, {}),
1652
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitTrigger, {})
1653
+ ] }),
1654
+ justSubmitted && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
1655
+ isFormOpen && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitForm, {}),
1656
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(FeatureList, {}),
1657
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Branding, {})
1658
+ ] });
1659
+ }
1660
+ function BoardHeading() {
1661
+ const { labels, cx } = useBoard();
1662
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("h2", { className: cx("fw-heading"), children: labels.title });
1663
+ }
1664
+ function BoardTabs() {
1665
+ const { tabs, tab, setTab, labels, cx } = useBoard();
1666
+ if (tabs.length < 2) return null;
1667
+ const labelOf = {
1668
+ top: labels.tabTop,
1669
+ new: labels.tabNew,
1670
+ roadmap: labels.tabRoadmap
1671
+ };
1672
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: cx("fw-tabs"), role: "tablist", "aria-label": labels.title, children: tabs.map((name) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1673
+ "button",
1674
+ {
1675
+ type: "button",
1676
+ role: "tab",
1677
+ id: `fw-tab-${name}`,
1678
+ className: cx("fw-tab"),
1679
+ "aria-selected": tab === name,
1680
+ tabIndex: tab === name ? 0 : -1,
1681
+ onClick: () => setTab(name),
1682
+ onKeyDown: (event) => {
1683
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
1684
+ if (step === 0) return;
1685
+ event.preventDefault();
1686
+ const next = tabs[(tabs.indexOf(tab) + step + tabs.length) % tabs.length];
1687
+ if (next) {
1688
+ setTab(next);
1689
+ document.getElementById(`fw-tab-${next}`)?.focus();
1690
+ }
1691
+ },
1692
+ children: labelOf[name]
1693
+ },
1694
+ name
1695
+ )) });
1696
+ }
1697
+ function SubmitTrigger() {
1698
+ const { isFormOpen, setFormOpen, setJustSubmitted, labels, cx } = useBoard();
1699
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1700
+ "button",
1701
+ {
1702
+ type: "button",
1703
+ className: cx("fw-btn"),
1704
+ "aria-expanded": isFormOpen,
1705
+ onClick: () => {
1706
+ if (!isFormOpen) setJustSubmitted(false);
1707
+ setFormOpen(!isFormOpen);
1708
+ },
1709
+ children: isFormOpen ? labels.cancel : labels.newRequest
1710
+ }
1711
+ );
1712
+ }
1713
+ var FewchursBoard = Object.assign(BoardRoot, {
1714
+ Root: BoardRoot,
1715
+ Tabs: BoardTabs,
1716
+ List: FeatureList,
1717
+ SubmitTrigger,
1718
+ SubmitForm,
1719
+ Branding
1720
+ });
1721
+
1722
+ // src/components/BoardDialog.tsx
1723
+ var import_react16 = require("react");
1724
+ var import_jsx_runtime12 = require("react/jsx-runtime");
1725
+ function FewchursBoardDialog({
1726
+ open,
1727
+ onClose,
1728
+ ariaLabel = "Feature requests",
1729
+ ...boardProps
1730
+ }) {
1731
+ const dialogRef = (0, import_react16.useRef)(null);
1732
+ const openerRef = (0, import_react16.useRef)(null);
1733
+ (0, import_react16.useEffect)(() => {
1734
+ if (!open) return;
1735
+ openerRef.current = document.activeElement;
1736
+ dialogRef.current?.focus();
1737
+ function onKeyDown(event) {
1738
+ if (event.key === "Escape") onClose();
1739
+ }
1740
+ document.addEventListener("keydown", onKeyDown);
1741
+ return () => {
1742
+ document.removeEventListener("keydown", onKeyDown);
1743
+ openerRef.current?.focus?.();
1744
+ };
1745
+ }, [open, onClose]);
1746
+ if (!open) return null;
1747
+ return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "fw-overlay", onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
1748
+ "div",
1749
+ {
1750
+ ref: dialogRef,
1751
+ className: "fw-dialog",
1752
+ role: "dialog",
1753
+ "aria-modal": "true",
1754
+ "aria-label": ariaLabel,
1755
+ tabIndex: -1,
1756
+ onClick: (event) => event.stopPropagation(),
1757
+ children: [
1758
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("button", { type: "button", className: "fw-dialog__close", "aria-label": "Close", onClick: onClose, children: "\xD7" }),
1759
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(BoardRoot, { ...boardProps })
1760
+ ]
1761
+ }
1762
+ ) });
1763
+ }
1764
+ // Annotate the CommonJS export names for ESM import in node:
1765
+ 0 && (module.exports = {
1766
+ BoardRoot,
1767
+ BoardTabs,
1768
+ Branding,
1769
+ CommentForm,
1770
+ CommentList,
1771
+ DEFAULT_BASE_URL,
1772
+ DEFAULT_LABELS,
1773
+ DEFAULT_PAGE_SIZE,
1774
+ DEVICE_ID_COOKIE,
1775
+ FeatureCard,
1776
+ FeatureList,
1777
+ FewchursBoard,
1778
+ FewchursBoardDialog,
1779
+ FewchursProvider,
1780
+ FollowForm,
1781
+ LIMITS,
1782
+ ROADMAP_STATUSES,
1783
+ SDK_HEADER,
1784
+ SDK_VERSION,
1785
+ StatusBadge,
1786
+ SubmitForm,
1787
+ SubmitTrigger,
1788
+ VoteButton,
1789
+ commentsStateOf,
1790
+ createDeviceId,
1791
+ createFewchursClient,
1792
+ createStore,
1793
+ errorFromResponse,
1794
+ featureKey,
1795
+ listKey,
1796
+ listStateOf,
1797
+ mergeLabels,
1798
+ networkError,
1799
+ normalizeComment,
1800
+ normalizeCommentPage,
1801
+ normalizeConfig,
1802
+ normalizeFeature,
1803
+ normalizeFeaturePage,
1804
+ normalizeStatus,
1805
+ normalizeVote,
1806
+ readDeviceId,
1807
+ resolveDeviceId,
1808
+ useBoard,
1809
+ useComments,
1810
+ useFeature,
1811
+ useFeatures,
1812
+ useFewchurs,
1813
+ useFewchursConfig,
1814
+ useFollow,
1815
+ useSubmitFeature,
1816
+ useVote,
1817
+ userMessage,
1818
+ validateCommentInput,
1819
+ validateEmail,
1820
+ validateFeatureInput,
1821
+ validationError
1822
+ });
1823
+ //# sourceMappingURL=index.js.map