@fewchurs/react 1.4.0 → 1.6.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 CHANGED
@@ -4,7 +4,7 @@
4
4
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
5
5
 
6
6
  // src/client/version.ts
7
- var SDK_VERSION = "1.4.0";
7
+ var SDK_VERSION = "1.6.0";
8
8
 
9
9
  // src/client/constants.ts
10
10
  var SDK_HEADER = `react/${SDK_VERSION}`;
@@ -22,17 +22,28 @@ var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
22
22
  var ROADMAP_STATUSES = ["planned", "in_progress", "done"];
23
23
 
24
24
  // src/client/errors.ts
25
+ var NOT_CONFIGURED_MESSAGE = "Fewchurs is not configured.";
25
26
  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."
27
+ invalid_key: "Invalid API key. Check the key in your Fewchurs dashboard.",
28
+ validation_failed: "Invalid input.",
29
+ rate_limited: "You've reached today's limit. Try again tomorrow.",
30
+ not_found: "This request no longer exists.",
31
+ limit_reached: "This board isn't accepting requests right now.",
32
+ server: "Something went wrong. Try again.",
33
+ network: "You're offline."
33
34
  };
35
+ function isWrongApp(error) {
36
+ return error.code === "invalid_key" && error.status === 403;
37
+ }
38
+ function isNotConfigured(error) {
39
+ return error.code === "server" && error.message === NOT_CONFIGURED_MESSAGE;
40
+ }
41
+ function notConfiguredError() {
42
+ return { code: "server", status: 500, message: NOT_CONFIGURED_MESSAGE };
43
+ }
34
44
  function userMessage(error) {
35
45
  if (error.code === "validation_failed" && error.message) return error.message;
46
+ if (isWrongApp(error) && error.message) return error.message;
36
47
  return USER_MESSAGES[error.code];
37
48
  }
38
49
  function networkError(message = "Network request failed.") {
@@ -43,17 +54,17 @@ function validationError(message) {
43
54
  }
44
55
  function errorFromResponse(status, body) {
45
56
  const code = body?.error?.code;
46
- const message = body?.error?.message ?? `Request failed (${status}).`;
57
+ const serverMessage = body?.error?.message;
58
+ const message = serverMessage ?? `Request failed (${status}).`;
47
59
  switch (code) {
48
60
  case "invalid_key":
49
61
  return { code: "invalid_key", status: 401, message };
50
62
  case "wrong_app":
51
63
  return { code: "invalid_key", status: 403, message };
52
- // missing_device_id is a client mistake the caller fixes the same way a bad field is fixed,
53
- // so it collapses into validation_failed rather than widening the public union.
54
64
  case "missing_device_id":
65
+ return { code: "server", status: 400, message };
55
66
  case "validation_failed":
56
- return { code: "validation_failed", status: 400, message };
67
+ return { code: "validation_failed", status: 400, message: serverMessage ?? "Invalid input." };
57
68
  case "rate_limited":
58
69
  return { code: "rate_limited", status: 429, message };
59
70
  case "not_found":
@@ -147,11 +158,8 @@ function normalizeVote(value) {
147
158
  // src/client/validate.ts
148
159
  function validateFeatureInput(input) {
149
160
  const title = input.title.trim();
150
- if (title.length < LIMITS.titleMin) {
151
- return validationError(`Title must be at least ${LIMITS.titleMin} characters.`);
152
- }
153
- if (title.length > LIMITS.titleMax) {
154
- return validationError(`Title must be at most ${LIMITS.titleMax} characters.`);
161
+ if (title.length < LIMITS.titleMin || title.length > LIMITS.titleMax) {
162
+ return validationError(`Title must be ${LIMITS.titleMin}\u2013${LIMITS.titleMax} characters.`);
155
163
  }
156
164
  if ((input.description ?? "").trim().length > LIMITS.descriptionMax) {
157
165
  return validationError(`Description must be at most ${LIMITS.descriptionMax} characters.`);
@@ -163,9 +171,8 @@ function validateFeatureInput(input) {
163
171
  }
164
172
  function validateCommentInput(input) {
165
173
  const text = input.text.trim();
166
- if (text.length < LIMITS.commentMin) return validationError("Write a comment first.");
167
- if (text.length > LIMITS.commentMax) {
168
- return validationError(`Comment must be at most ${LIMITS.commentMax} characters.`);
174
+ if (text.length < LIMITS.commentMin || text.length > LIMITS.commentMax) {
175
+ return validationError(`Comment must be ${LIMITS.commentMin}\u2013${LIMITS.commentMax} characters.`);
169
176
  }
170
177
  if ((input.authorName ?? "").trim().length > LIMITS.authorNameMax) {
171
178
  return validationError(`Name must be at most ${LIMITS.authorNameMax} characters.`);
@@ -185,6 +192,7 @@ function createFewchursClient(options = {}) {
185
192
  const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
186
193
  const retries = options.retries ?? 1;
187
194
  const retryDelayMs = options.retryDelayMs ?? 250;
195
+ if (options.apiKey && !options.apiKey.startsWith("fr_")) logOnce(BAD_KEY_PREFIX);
188
196
  function getDeviceId() {
189
197
  return typeof options.deviceId === "function" ? options.deviceId() : options.deviceId ?? null;
190
198
  }
@@ -211,6 +219,11 @@ function createFewchursClient(options = {}) {
211
219
  }
212
220
  async function request(path, map, { method = "GET", body, query, signal } = {}) {
213
221
  if (!doFetch) return { ok: false, error: networkError("No fetch implementation available.") };
222
+ if (!options.apiKey && options.baseUrl === void 0) {
223
+ const failure = notConfiguredError();
224
+ logError(failure);
225
+ return { ok: false, error: failure };
226
+ }
214
227
  const target = url(path, query);
215
228
  const init = { method, headers: headers(body !== void 0) };
216
229
  if (body !== void 0) init.body = JSON.stringify(body);
@@ -330,16 +343,21 @@ function delay(ms) {
330
343
  function messageOf(error) {
331
344
  return error instanceof Error ? error.message : "Network request failed.";
332
345
  }
333
- var INVALID_KEY_HELP = "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.";
346
+ var INVALID_KEY_HELP = "Fewchurs rejected the API key. Check the key in your dashboard (Apps \u2192 API keys).";
347
+ var NOT_CONFIGURED_HELP = "Fewchurs is not configured. Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.";
348
+ var BAD_KEY_PREFIX = 'The Fewchurs API key should start with "fr_". Copy it from your dashboard.';
334
349
  var loggedErrors = /* @__PURE__ */ new Set();
335
- function logError(error) {
350
+ function logOnce(detail) {
336
351
  if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
337
- const isInvalidKey = error.code === "invalid_key" && error.status === 401;
338
- const detail = isInvalidKey ? INVALID_KEY_HELP : error.message;
339
352
  if (loggedErrors.has(detail)) return;
340
353
  loggedErrors.add(detail);
341
354
  console.warn(`[fewchurs] : ${detail}`);
342
355
  }
356
+ function logError(error) {
357
+ if (isNotConfigured(error)) return logOnce(NOT_CONFIGURED_HELP);
358
+ const isInvalidKey = error.code === "invalid_key" && error.status === 401;
359
+ logOnce(isInvalidKey ? INVALID_KEY_HELP : error.message);
360
+ }
343
361
 
344
362
  // src/identity/deviceId.ts
345
363
  var COOKIE_NAME = "fewchurs_did";
@@ -715,7 +733,8 @@ function useFewchurs() {
715
733
  }
716
734
 
717
735
  // src/components/Board.tsx
718
- import { useEffect as useEffect6, useMemo as useMemo3, useState as useState10 } from "react";
736
+ import { useEffect as useEffect6, useMemo as useMemo3, useRef as useRef2, useState as useState10 } from "react";
737
+ import { flushSync } from "react-dom";
719
738
 
720
739
  // src/hooks/useFewchursConfig.ts
721
740
  import { useEffect as useEffect2 } from "react";
@@ -769,11 +788,19 @@ var DEFAULT_LABELS = {
769
788
  loadMore: "Load more",
770
789
  retry: "Try again",
771
790
  errorInvalidKeyTitle: "Invalid API key",
772
- errorInvalidKeyDetail: "We can't connect to this board right now. The owner of this site needs to check its setup.",
773
- errorWrongAppTitle: "Wrong platform",
774
- errorWrongAppDetail: "This board isn't set up for this site or app yet. Its owner needs to add it in Fewchurs.",
775
- errorTitle: "Something went wrong",
776
- errorDetail: "We couldn't load the requests. Please try again.",
791
+ errorInvalidKeyDetail: "Copy the key from your Fewchurs dashboard (Apps \u2192 API keys) and pass it to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
792
+ errorWrongAppTitle: "Invalid API key",
793
+ errorWrongAppDetail: "",
794
+ errorTitle: "Couldn't load requests",
795
+ errorDetail: "Something went wrong. Try again.",
796
+ errorOfflineTitle: "You're offline",
797
+ errorOfflineDetail: "Check your connection and try again.",
798
+ errorNotSetUpTitle: "Fewchurs isn't set up",
799
+ errorNotSetUpDetail: "Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
800
+ errorLoadMore: "Couldn't load more.",
801
+ errorComments: "Couldn't load comments.",
802
+ fieldTitleTooShort: "At least 3 characters.",
803
+ fieldEmailInvalid: "Enter a valid email address.",
777
804
  comments: "Comments",
778
805
  commentPlaceholder: "Add a comment",
779
806
  postComment: "Post",
@@ -972,6 +999,7 @@ function useComments(featureId, { enabled = true } = {}) {
972
999
  store.ensureComments(featureId);
973
1000
  }, [store, featureId, enabled, isReady]);
974
1001
  const loadMore = useCallback5(() => store.loadMoreComments(featureId), [store, featureId]);
1002
+ const refresh = useCallback5(() => store.refreshComments(featureId), [store, featureId]);
975
1003
  const add = useCallback5(
976
1004
  async (text, authorName) => {
977
1005
  setIsSubmitting(true);
@@ -992,6 +1020,7 @@ function useComments(featureId, { enabled = true } = {}) {
992
1020
  submitError,
993
1021
  hasMore: comments.cursor !== null,
994
1022
  loadMore,
1023
+ refresh,
995
1024
  add
996
1025
  };
997
1026
  }
@@ -1011,10 +1040,15 @@ function formatDate(iso) {
1011
1040
  import { jsx as jsx3, jsxs } from "react/jsx-runtime";
1012
1041
  function CommentList({ featureId }) {
1013
1042
  const { labels, cx } = useBoard();
1014
- const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
1043
+ const { comments, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useComments(featureId);
1015
1044
  return /* @__PURE__ */ jsxs("div", { className: cx("fw-comments"), children: [
1016
1045
  isLoading && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.loading }),
1017
- error && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1046
+ error && comments.length === 0 && /* @__PURE__ */ jsxs("div", { className: cx("fw-empty", "fw-empty--compact"), role: "alert", children: [
1047
+ /* @__PURE__ */ jsx3("p", { className: cx("fw-empty__title"), children: labels.errorComments }),
1048
+ /* @__PURE__ */ jsx3("p", { className: cx("fw-empty__detail"), children: isNotConfigured(error) ? labels.errorNotSetUpTitle : userMessage(error) }),
1049
+ /* @__PURE__ */ jsx3("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1050
+ ] }),
1051
+ error && comments.length > 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1018
1052
  !isLoading && !error && comments.length === 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.noComments }),
1019
1053
  /* @__PURE__ */ jsx3("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ jsxs("li", { className: cx("fw-comment"), children: [
1020
1054
  /* @__PURE__ */ jsx3("span", { className: cx("fw-comment__author"), children: comment.authorName }),
@@ -1085,7 +1119,7 @@ function CommentForm({ featureId }) {
1085
1119
  }
1086
1120
  )
1087
1121
  ] }),
1088
- /* @__PURE__ */ jsx3("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
1122
+ /* @__PURE__ */ jsx3("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting || !text.trim(), children: labels.postComment }),
1089
1123
  submitError && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
1090
1124
  ]
1091
1125
  }
@@ -1094,27 +1128,31 @@ function CommentForm({ featureId }) {
1094
1128
 
1095
1129
  // src/components/FollowForm.tsx
1096
1130
  import { useState as useState6 } from "react";
1097
- import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1131
+ import { Fragment, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1098
1132
  function FollowForm({ feature }) {
1099
1133
  const { labels, userEmail, cx } = useBoard();
1100
1134
  const { isFollowing, isPending, error, follow, unfollow } = useFollow(feature.id);
1101
1135
  const [email, setEmail] = useState6(userEmail ?? "");
1102
1136
  const [isOpen, setIsOpen] = useState6(false);
1103
1137
  if (isFollowing) {
1104
- return /* @__PURE__ */ jsx4(
1105
- "button",
1106
- {
1107
- type: "button",
1108
- className: cx("fw-link"),
1109
- disabled: isPending,
1110
- onClick: () => void unfollow(),
1111
- children: labels.following
1112
- }
1113
- );
1138
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
1139
+ /* @__PURE__ */ jsx4(
1140
+ "button",
1141
+ {
1142
+ type: "button",
1143
+ className: cx("fw-link"),
1144
+ disabled: isPending,
1145
+ onClick: () => void unfollow(),
1146
+ children: labels.following
1147
+ }
1148
+ ),
1149
+ error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1150
+ ] });
1114
1151
  }
1115
1152
  if (!isOpen) {
1116
1153
  return /* @__PURE__ */ jsx4("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
1117
1154
  }
1155
+ const isEmailValid = EMAIL_PATTERN.test(email.trim());
1118
1156
  return /* @__PURE__ */ jsxs2(
1119
1157
  "form",
1120
1158
  {
@@ -1138,7 +1176,8 @@ function FollowForm({ feature }) {
1138
1176
  required: true
1139
1177
  }
1140
1178
  ),
1141
- /* @__PURE__ */ jsx4("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: labels.follow }),
1179
+ email !== "" && !isEmailValid && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), children: labels.fieldEmailInvalid }),
1180
+ /* @__PURE__ */ jsx4("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !isEmailValid, children: labels.follow }),
1142
1181
  error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1143
1182
  ]
1144
1183
  }
@@ -1181,7 +1220,7 @@ function FeatureCard({
1181
1220
  defaultCommentsOpen = false
1182
1221
  }) {
1183
1222
  const { labels, allowComments, cx } = useBoard();
1184
- const { isPending, toggle } = useVote(feature.id);
1223
+ const { isPending, toggle, error: voteError } = useVote(feature.id);
1185
1224
  const [showComments, setShowComments] = useState7(defaultCommentsOpen);
1186
1225
  return /* @__PURE__ */ jsxs4("li", { className: cx("fw-card"), children: [
1187
1226
  /* @__PURE__ */ jsx7(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
@@ -1209,6 +1248,7 @@ function FeatureCard({
1209
1248
  ),
1210
1249
  /* @__PURE__ */ jsx7(FollowForm, { feature })
1211
1250
  ] }),
1251
+ voteError && /* @__PURE__ */ jsx7("p", { className: cx("fw-error"), role: "alert", children: userMessage(voteError) }),
1212
1252
  allowComments && showComments && /* @__PURE__ */ jsx7("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ jsx7(CommentList, { featureId: feature.id }) })
1213
1253
  ] })
1214
1254
  ] });
@@ -1248,24 +1288,42 @@ function Bar({ width, height }) {
1248
1288
  }
1249
1289
 
1250
1290
  // src/components/FeatureList.tsx
1251
- import { Fragment, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1291
+ import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1252
1292
  function errorCopy(error, labels) {
1253
- if (error.code !== "invalid_key") {
1254
- return { title: labels.errorTitle, detail: labels.errorDetail };
1293
+ if (isNotConfigured(error)) {
1294
+ return { title: labels.errorNotSetUpTitle, detail: labels.errorNotSetUpDetail };
1295
+ }
1296
+ if (error.code === "network") {
1297
+ return { title: labels.errorOfflineTitle, detail: labels.errorOfflineDetail };
1298
+ }
1299
+ if (isWrongApp(error)) {
1300
+ return {
1301
+ title: labels.errorWrongAppTitle,
1302
+ detail: labels.errorWrongAppDetail || userMessage(error)
1303
+ };
1304
+ }
1305
+ if (error.code === "invalid_key") {
1306
+ return { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1255
1307
  }
1256
- return error.status === 403 ? { title: labels.errorWrongAppTitle, detail: labels.errorWrongAppDetail } : { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1308
+ return {
1309
+ title: labels.errorTitle,
1310
+ detail: error.code === "server" ? labels.errorDetail : userMessage(error)
1311
+ };
1257
1312
  }
1258
1313
  function FeatureList() {
1259
1314
  const { initialFeatureId } = useBoard();
1260
- return /* @__PURE__ */ jsxs6(Fragment, { children: [
1315
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1261
1316
  initialFeatureId && /* @__PURE__ */ jsx9(PinnedFeature, { featureId: initialFeatureId }),
1262
1317
  /* @__PURE__ */ jsx9(Listing, { excludeId: initialFeatureId })
1263
1318
  ] });
1264
1319
  }
1265
1320
  function PinnedFeature({ featureId }) {
1266
1321
  const { cx } = useBoard();
1267
- const { feature, isLoading } = useFeature(featureId);
1322
+ const { feature, isLoading, error } = useFeature(featureId);
1268
1323
  if (isLoading) return /* @__PURE__ */ jsx9(FeatureListSkeleton, { rows: 1 });
1324
+ if (!feature && error) {
1325
+ return /* @__PURE__ */ jsx9("p", { className: cx("fw-error", "fw-pinned"), role: "alert", children: userMessage(error) });
1326
+ }
1269
1327
  if (!feature) return null;
1270
1328
  return /* @__PURE__ */ jsx9("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ jsx9("ul", { className: cx("fw-list"), children: /* @__PURE__ */ jsx9(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
1271
1329
  }
@@ -1288,22 +1346,26 @@ function Listing({ excludeId }) {
1288
1346
  return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), role: "alert", children: [
1289
1347
  /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: title }),
1290
1348
  /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__detail"), children: detail }),
1291
- /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1349
+ !isNotConfigured(error) && /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1292
1350
  ] });
1293
1351
  }
1294
1352
  if (isLoading && visible.length === 0) {
1295
1353
  return /* @__PURE__ */ jsx9(FeatureListSkeleton, { roadmap: isRoadmap });
1296
1354
  }
1297
1355
  if (visible.length === 0) {
1298
- if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment, { children: renderEmpty() });
1356
+ if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment2, { children: renderEmpty() });
1299
1357
  return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), children: [
1300
1358
  /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: labels.emptyTitle }),
1301
1359
  /* @__PURE__ */ jsx9("p", { children: labels.emptyBody })
1302
1360
  ] });
1303
1361
  }
1304
- return /* @__PURE__ */ jsxs6(Fragment, { children: [
1362
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1305
1363
  isRoadmap ? /* @__PURE__ */ jsx9(RoadmapColumns, { features: visible }) : /* @__PURE__ */ jsx9(Rows2, { features: visible }),
1306
- hasMore && /* @__PURE__ */ jsx9("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ jsx9(
1364
+ hasMore && error && /* @__PURE__ */ jsxs6("div", { className: cx("fw-pagination"), role: "alert", children: [
1365
+ /* @__PURE__ */ jsx9("span", { children: labels.errorLoadMore }),
1366
+ /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void loadMore(), children: labels.retry })
1367
+ ] }),
1368
+ hasMore && !error && /* @__PURE__ */ jsx9("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ jsx9(
1307
1369
  "button",
1308
1370
  {
1309
1371
  type: "button",
@@ -1346,7 +1408,7 @@ function FeatureSlot({ feature }) {
1346
1408
  const { renderFeature } = useBoard();
1347
1409
  const { toggle } = useVote(feature.id);
1348
1410
  const { follow, unfollow } = useFollow(feature.id);
1349
- return /* @__PURE__ */ jsx9(Fragment, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1411
+ return /* @__PURE__ */ jsx9(Fragment2, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1350
1412
  }
1351
1413
 
1352
1414
  // src/components/SubmitForm.tsx
@@ -1379,11 +1441,24 @@ function useSubmitFeature() {
1379
1441
  // src/components/SubmitForm.tsx
1380
1442
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
1381
1443
  function SubmitForm() {
1382
- const { labels, emailField, userEmail, isSubscriber, setFormOpen, setJustSubmitted, cx } = useBoard();
1444
+ const {
1445
+ labels,
1446
+ emailField,
1447
+ userEmail,
1448
+ isSubscriber,
1449
+ isFormOpen,
1450
+ setFormOpen,
1451
+ setJustSubmitted,
1452
+ cx
1453
+ } = useBoard();
1383
1454
  const { submit, isPending, error } = useSubmitFeature();
1384
1455
  const [title, setTitle] = useState9("");
1385
1456
  const [description, setDescription] = useState9("");
1386
1457
  const [email, setEmail] = useState9(userEmail ?? "");
1458
+ const isTitleValid = title.trim().length >= LIMITS.titleMin;
1459
+ const isEmailInvalid = emailField !== "off" && email !== "" && !EMAIL_PATTERN.test(email.trim());
1460
+ const isEmailMissing = emailField === "required" && !email.trim();
1461
+ const canSubmit = isTitleValid && !isEmailInvalid && !isEmailMissing;
1387
1462
  return /* @__PURE__ */ jsxs7(
1388
1463
  "form",
1389
1464
  {
@@ -1397,8 +1472,10 @@ function SubmitForm() {
1397
1472
  isSubscriber
1398
1473
  });
1399
1474
  if (!created) return;
1400
- setTitle("");
1401
- setDescription("");
1475
+ if (!isFormOpen) {
1476
+ setTitle("");
1477
+ setDescription("");
1478
+ }
1402
1479
  setJustSubmitted(true);
1403
1480
  setFormOpen(false);
1404
1481
  },
@@ -1417,7 +1494,15 @@ function SubmitForm() {
1417
1494
  onChange: (event) => setTitle(event.target.value),
1418
1495
  required: true
1419
1496
  }
1420
- )
1497
+ ),
1498
+ /* @__PURE__ */ jsxs7("div", { className: cx("fw-field__foot"), children: [
1499
+ /* @__PURE__ */ jsx10("span", { className: cx("fw-error"), children: title !== "" && !isTitleValid ? labels.fieldTitleTooShort : "" }),
1500
+ /* @__PURE__ */ jsxs7("span", { className: cx("fw-muted"), children: [
1501
+ title.length,
1502
+ "/",
1503
+ LIMITS.titleMax
1504
+ ] })
1505
+ ] })
1421
1506
  ] }),
1422
1507
  /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
1423
1508
  /* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
@@ -1431,7 +1516,15 @@ function SubmitForm() {
1431
1516
  placeholder: labels.fieldDescriptionPlaceholder,
1432
1517
  onChange: (event) => setDescription(event.target.value)
1433
1518
  }
1434
- )
1519
+ ),
1520
+ /* @__PURE__ */ jsxs7("div", { className: cx("fw-field__foot"), children: [
1521
+ /* @__PURE__ */ jsx10("span", {}),
1522
+ /* @__PURE__ */ jsxs7("span", { className: cx("fw-muted"), children: [
1523
+ description.length,
1524
+ "/",
1525
+ LIMITS.descriptionMax
1526
+ ] })
1527
+ ] })
1435
1528
  ] }),
1436
1529
  emailField !== "off" && /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
1437
1530
  /* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
@@ -1448,11 +1541,11 @@ function SubmitForm() {
1448
1541
  required: emailField === "required"
1449
1542
  }
1450
1543
  ),
1451
- /* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1544
+ isEmailInvalid ? /* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-error"), children: labels.fieldEmailInvalid }) : /* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1452
1545
  ] }),
1453
1546
  error && /* @__PURE__ */ jsx10("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1454
1547
  /* @__PURE__ */ jsxs7("div", { className: cx("fw-form__actions"), children: [
1455
- /* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: isPending ? labels.submitting : labels.submit }),
1548
+ /* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !canSubmit, children: isPending ? labels.submitting : labels.submit }),
1456
1549
  /* @__PURE__ */ jsx10(
1457
1550
  "button",
1458
1551
  {
@@ -1490,7 +1583,7 @@ function themeToStyle(theme) {
1490
1583
  }
1491
1584
 
1492
1585
  // src/components/Board.tsx
1493
- import { Fragment as Fragment2, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
1586
+ import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
1494
1587
  var DEFAULT_TABS = ["top", "new"];
1495
1588
  function BoardRoot({
1496
1589
  tabs = DEFAULT_TABS,
@@ -1576,19 +1669,92 @@ function BoardRoot({
1576
1669
  ) });
1577
1670
  }
1578
1671
  function DefaultBoard() {
1579
- const { isFormOpen, justSubmitted, labels, renderHeader, cx } = useBoard();
1580
- return /* @__PURE__ */ jsxs8(Fragment2, { children: [
1672
+ const { renderHeader } = useBoard();
1673
+ return /* @__PURE__ */ jsxs8(Fragment3, { children: [
1581
1674
  renderHeader ? renderHeader() : /* @__PURE__ */ jsx11(BoardHeading, {}),
1582
- /* @__PURE__ */ jsxs8("div", { className: cx("fw-toolbar"), children: [
1583
- /* @__PURE__ */ jsx11(BoardTabs, {}),
1584
- /* @__PURE__ */ jsx11(SubmitTrigger, {})
1585
- ] }),
1586
- justSubmitted && /* @__PURE__ */ jsx11("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
1587
- isFormOpen && /* @__PURE__ */ jsx11(SubmitForm, {}),
1588
- /* @__PURE__ */ jsx11(FeatureList, {}),
1675
+ /* @__PURE__ */ jsx11(BoardPanels, {}),
1589
1676
  /* @__PURE__ */ jsx11(Branding, {})
1590
1677
  ] });
1591
1678
  }
1679
+ var LEAVE_MS = 110;
1680
+ var ENTER_MS = 300;
1681
+ var ENTER_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
1682
+ function canAnimate(element) {
1683
+ if (!element || typeof element.animate !== "function") return false;
1684
+ return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
1685
+ }
1686
+ function BoardPanels() {
1687
+ const { isFormOpen, setFormOpen, justSubmitted, setJustSubmitted, labels, unstyled, cx } = useBoard();
1688
+ const [showForm, setShowForm] = useState10(isFormOpen);
1689
+ const panel = useRef2(null);
1690
+ const trigger = useRef2(null);
1691
+ const entering = useRef2(null);
1692
+ const hasSwapped = useRef2(false);
1693
+ useEffect6(() => {
1694
+ if (isFormOpen === showForm) return;
1695
+ hasSwapped.current = true;
1696
+ const element = panel.current;
1697
+ if (unstyled || !canAnimate(element)) {
1698
+ setShowForm(isFormOpen);
1699
+ return;
1700
+ }
1701
+ entering.current?.cancel();
1702
+ const leaving = element.animate(
1703
+ { opacity: [1, 0], transform: ["translateY(0)", "translateY(-4px)"] },
1704
+ { duration: LEAVE_MS, easing: "ease-in", fill: "forwards" }
1705
+ );
1706
+ leaving.onfinish = () => {
1707
+ const from = element.offsetHeight;
1708
+ flushSync(() => setShowForm(isFormOpen));
1709
+ leaving.cancel();
1710
+ const to = element.offsetHeight;
1711
+ element.style.overflow = "hidden";
1712
+ const enter = element.animate(
1713
+ {
1714
+ opacity: [0, 1],
1715
+ transform: ["translateY(6px)", "translateY(0)"],
1716
+ height: [`${from}px`, `${to}px`]
1717
+ },
1718
+ { duration: ENTER_MS, easing: ENTER_EASING }
1719
+ );
1720
+ const finish = () => {
1721
+ element.style.overflow = "";
1722
+ };
1723
+ enter.onfinish = finish;
1724
+ enter.oncancel = finish;
1725
+ entering.current = enter;
1726
+ };
1727
+ return () => leaving.cancel();
1728
+ }, [isFormOpen, showForm, unstyled]);
1729
+ useEffect6(() => {
1730
+ if (!hasSwapped.current) return;
1731
+ const target = showForm ? panel.current?.querySelector("#fw-title") : trigger.current;
1732
+ target?.focus({ preventScroll: true });
1733
+ }, [showForm]);
1734
+ return /* @__PURE__ */ jsxs8("div", { ref: panel, children: [
1735
+ /* @__PURE__ */ jsxs8("div", { hidden: showForm, children: [
1736
+ /* @__PURE__ */ jsxs8("div", { className: cx("fw-toolbar"), children: [
1737
+ /* @__PURE__ */ jsx11(BoardTabs, {}),
1738
+ /* @__PURE__ */ jsx11(
1739
+ "button",
1740
+ {
1741
+ ref: trigger,
1742
+ type: "button",
1743
+ className: cx("fw-btn"),
1744
+ onClick: () => {
1745
+ setJustSubmitted(false);
1746
+ setFormOpen(true);
1747
+ },
1748
+ children: labels.newRequest
1749
+ }
1750
+ )
1751
+ ] }),
1752
+ justSubmitted && /* @__PURE__ */ jsx11("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
1753
+ /* @__PURE__ */ jsx11(FeatureList, {})
1754
+ ] }),
1755
+ showForm && /* @__PURE__ */ jsx11(SubmitForm, {})
1756
+ ] });
1757
+ }
1592
1758
  function BoardHeading() {
1593
1759
  const { labels, cx } = useBoard();
1594
1760
  return /* @__PURE__ */ jsx11("h2", { className: cx("fw-heading"), children: labels.title });
@@ -1652,7 +1818,7 @@ var FewchursBoard = Object.assign(BoardRoot, {
1652
1818
  });
1653
1819
 
1654
1820
  // src/components/BoardDialog.tsx
1655
- import { useEffect as useEffect7, useRef as useRef2 } from "react";
1821
+ import { useEffect as useEffect7, useRef as useRef3 } from "react";
1656
1822
  import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
1657
1823
  function FewchursBoardDialog({
1658
1824
  open,
@@ -1661,8 +1827,8 @@ function FewchursBoardDialog({
1661
1827
  variant = "dialog",
1662
1828
  ...boardProps
1663
1829
  }) {
1664
- const dialogRef = useRef2(null);
1665
- const openerRef = useRef2(null);
1830
+ const dialogRef = useRef3(null);
1831
+ const openerRef = useRef3(null);
1666
1832
  useEffect7(() => {
1667
1833
  if (!open) return;
1668
1834
  openerRef.current = document.activeElement;
@@ -1701,7 +1867,7 @@ function FewchursBoardDialog({
1701
1867
  // src/components/BoardButton.tsx
1702
1868
  import { useCallback as useCallback7, useState as useState11 } from "react";
1703
1869
  import { createPortal } from "react-dom";
1704
- import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
1870
+ import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
1705
1871
  function launcherClasses(floating) {
1706
1872
  if (!floating) return "fw-launcher";
1707
1873
  const side = floating === "left" ? " fw-launcher--left" : "";
@@ -1718,7 +1884,7 @@ function FewchursButton({
1718
1884
  const [open, setOpen] = useState11(false);
1719
1885
  const close = useCallback7(() => setOpen(false), []);
1720
1886
  const classes = [board.unstyled ? "" : launcherClasses(floating), className].filter(Boolean).join(" ");
1721
- return /* @__PURE__ */ jsxs10(Fragment3, { children: [
1887
+ return /* @__PURE__ */ jsxs10(Fragment4, { children: [
1722
1888
  /* @__PURE__ */ jsx13(
1723
1889
  "button",
1724
1890
  {
@@ -1764,6 +1930,7 @@ export {
1764
1930
  FewchursProvider,
1765
1931
  FollowForm,
1766
1932
  LIMITS,
1933
+ NOT_CONFIGURED_MESSAGE,
1767
1934
  ROADMAP_STATUSES,
1768
1935
  SDK_HEADER,
1769
1936
  SDK_VERSION,