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