@fewchurs/react 1.3.0 → 1.5.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.3.0";
7
+ var SDK_VERSION = "1.5.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);
@@ -226,7 +239,9 @@ function createFewchursClient(options = {}) {
226
239
  await delay(retryDelayMs);
227
240
  continue;
228
241
  }
229
- return { ok: false, error: networkError(messageOf(error)) };
242
+ const failure2 = networkError(messageOf(error));
243
+ logError(failure2);
244
+ return { ok: false, error: failure2 };
230
245
  }
231
246
  if (response.ok) {
232
247
  const payload = await readJson(response);
@@ -237,7 +252,7 @@ function createFewchursClient(options = {}) {
237
252
  continue;
238
253
  }
239
254
  const failure = errorFromResponse(response.status, await readJson(response));
240
- warnAboutKey(failure);
255
+ logError(failure);
241
256
  return { ok: false, error: failure };
242
257
  }
243
258
  return { ok: false, error: networkError() };
@@ -328,14 +343,20 @@ function delay(ms) {
328
343
  function messageOf(error) {
329
344
  return error instanceof Error ? error.message : "Network request failed.";
330
345
  }
331
- var warnedAboutKey = false;
332
- function warnAboutKey(error) {
333
- if (error.code !== "invalid_key" || warnedAboutKey) return;
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.';
349
+ var loggedErrors = /* @__PURE__ */ new Set();
350
+ function logOnce(detail) {
334
351
  if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
335
- warnedAboutKey = true;
336
- console.warn(
337
- error.status === 403 ? `[fewchurs] ${error.message}` : "[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."
338
- );
352
+ if (loggedErrors.has(detail)) return;
353
+ loggedErrors.add(detail);
354
+ console.warn(`[fewchurs] : ${detail}`);
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);
339
360
  }
340
361
 
341
362
  // src/identity/deviceId.ts
@@ -765,6 +786,20 @@ var DEFAULT_LABELS = {
765
786
  loading: "Loading\u2026",
766
787
  loadMore: "Load more",
767
788
  retry: "Try again",
789
+ errorInvalidKeyTitle: "Invalid API key",
790
+ 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.",
791
+ errorWrongAppTitle: "Invalid API key",
792
+ errorWrongAppDetail: "",
793
+ errorTitle: "Couldn't load requests",
794
+ errorDetail: "Something went wrong. Try again.",
795
+ errorOfflineTitle: "You're offline",
796
+ errorOfflineDetail: "Check your connection and try again.",
797
+ errorNotSetUpTitle: "Fewchurs isn't set up",
798
+ errorNotSetUpDetail: "Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
799
+ errorLoadMore: "Couldn't load more.",
800
+ errorComments: "Couldn't load comments.",
801
+ fieldTitleTooShort: "At least 3 characters.",
802
+ fieldEmailInvalid: "Enter a valid email address.",
768
803
  comments: "Comments",
769
804
  commentPlaceholder: "Add a comment",
770
805
  postComment: "Post",
@@ -963,6 +998,7 @@ function useComments(featureId, { enabled = true } = {}) {
963
998
  store.ensureComments(featureId);
964
999
  }, [store, featureId, enabled, isReady]);
965
1000
  const loadMore = useCallback5(() => store.loadMoreComments(featureId), [store, featureId]);
1001
+ const refresh = useCallback5(() => store.refreshComments(featureId), [store, featureId]);
966
1002
  const add = useCallback5(
967
1003
  async (text, authorName) => {
968
1004
  setIsSubmitting(true);
@@ -983,6 +1019,7 @@ function useComments(featureId, { enabled = true } = {}) {
983
1019
  submitError,
984
1020
  hasMore: comments.cursor !== null,
985
1021
  loadMore,
1022
+ refresh,
986
1023
  add
987
1024
  };
988
1025
  }
@@ -1002,10 +1039,15 @@ function formatDate(iso) {
1002
1039
  import { jsx as jsx3, jsxs } from "react/jsx-runtime";
1003
1040
  function CommentList({ featureId }) {
1004
1041
  const { labels, cx } = useBoard();
1005
- const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
1042
+ const { comments, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useComments(featureId);
1006
1043
  return /* @__PURE__ */ jsxs("div", { className: cx("fw-comments"), children: [
1007
1044
  isLoading && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.loading }),
1008
- error && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1045
+ error && comments.length === 0 && /* @__PURE__ */ jsxs("div", { className: cx("fw-empty", "fw-empty--compact"), role: "alert", children: [
1046
+ /* @__PURE__ */ jsx3("p", { className: cx("fw-empty__title"), children: labels.errorComments }),
1047
+ /* @__PURE__ */ jsx3("p", { className: cx("fw-empty__detail"), children: isNotConfigured(error) ? labels.errorNotSetUpTitle : userMessage(error) }),
1048
+ /* @__PURE__ */ jsx3("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1049
+ ] }),
1050
+ error && comments.length > 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1009
1051
  !isLoading && !error && comments.length === 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.noComments }),
1010
1052
  /* @__PURE__ */ jsx3("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ jsxs("li", { className: cx("fw-comment"), children: [
1011
1053
  /* @__PURE__ */ jsx3("span", { className: cx("fw-comment__author"), children: comment.authorName }),
@@ -1076,7 +1118,7 @@ function CommentForm({ featureId }) {
1076
1118
  }
1077
1119
  )
1078
1120
  ] }),
1079
- /* @__PURE__ */ jsx3("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
1121
+ /* @__PURE__ */ jsx3("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting || !text.trim(), children: labels.postComment }),
1080
1122
  submitError && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
1081
1123
  ]
1082
1124
  }
@@ -1085,27 +1127,31 @@ function CommentForm({ featureId }) {
1085
1127
 
1086
1128
  // src/components/FollowForm.tsx
1087
1129
  import { useState as useState6 } from "react";
1088
- import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1130
+ import { Fragment, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1089
1131
  function FollowForm({ feature }) {
1090
1132
  const { labels, userEmail, cx } = useBoard();
1091
1133
  const { isFollowing, isPending, error, follow, unfollow } = useFollow(feature.id);
1092
1134
  const [email, setEmail] = useState6(userEmail ?? "");
1093
1135
  const [isOpen, setIsOpen] = useState6(false);
1094
1136
  if (isFollowing) {
1095
- return /* @__PURE__ */ jsx4(
1096
- "button",
1097
- {
1098
- type: "button",
1099
- className: cx("fw-link"),
1100
- disabled: isPending,
1101
- onClick: () => void unfollow(),
1102
- children: labels.following
1103
- }
1104
- );
1137
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
1138
+ /* @__PURE__ */ jsx4(
1139
+ "button",
1140
+ {
1141
+ type: "button",
1142
+ className: cx("fw-link"),
1143
+ disabled: isPending,
1144
+ onClick: () => void unfollow(),
1145
+ children: labels.following
1146
+ }
1147
+ ),
1148
+ error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1149
+ ] });
1105
1150
  }
1106
1151
  if (!isOpen) {
1107
1152
  return /* @__PURE__ */ jsx4("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
1108
1153
  }
1154
+ const isEmailValid = EMAIL_PATTERN.test(email.trim());
1109
1155
  return /* @__PURE__ */ jsxs2(
1110
1156
  "form",
1111
1157
  {
@@ -1129,7 +1175,8 @@ function FollowForm({ feature }) {
1129
1175
  required: true
1130
1176
  }
1131
1177
  ),
1132
- /* @__PURE__ */ jsx4("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: labels.follow }),
1178
+ email !== "" && !isEmailValid && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), children: labels.fieldEmailInvalid }),
1179
+ /* @__PURE__ */ jsx4("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !isEmailValid, children: labels.follow }),
1133
1180
  error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1134
1181
  ]
1135
1182
  }
@@ -1172,7 +1219,7 @@ function FeatureCard({
1172
1219
  defaultCommentsOpen = false
1173
1220
  }) {
1174
1221
  const { labels, allowComments, cx } = useBoard();
1175
- const { isPending, toggle } = useVote(feature.id);
1222
+ const { isPending, toggle, error: voteError } = useVote(feature.id);
1176
1223
  const [showComments, setShowComments] = useState7(defaultCommentsOpen);
1177
1224
  return /* @__PURE__ */ jsxs4("li", { className: cx("fw-card"), children: [
1178
1225
  /* @__PURE__ */ jsx7(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
@@ -1200,6 +1247,7 @@ function FeatureCard({
1200
1247
  ),
1201
1248
  /* @__PURE__ */ jsx7(FollowForm, { feature })
1202
1249
  ] }),
1250
+ voteError && /* @__PURE__ */ jsx7("p", { className: cx("fw-error"), role: "alert", children: userMessage(voteError) }),
1203
1251
  allowComments && showComments && /* @__PURE__ */ jsx7("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ jsx7(CommentList, { featureId: feature.id }) })
1204
1252
  ] })
1205
1253
  ] });
@@ -1239,18 +1287,42 @@ function Bar({ width, height }) {
1239
1287
  }
1240
1288
 
1241
1289
  // src/components/FeatureList.tsx
1242
- import { Fragment, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1290
+ import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1291
+ function errorCopy(error, labels) {
1292
+ if (isNotConfigured(error)) {
1293
+ return { title: labels.errorNotSetUpTitle, detail: labels.errorNotSetUpDetail };
1294
+ }
1295
+ if (error.code === "network") {
1296
+ return { title: labels.errorOfflineTitle, detail: labels.errorOfflineDetail };
1297
+ }
1298
+ if (isWrongApp(error)) {
1299
+ return {
1300
+ title: labels.errorWrongAppTitle,
1301
+ detail: labels.errorWrongAppDetail || userMessage(error)
1302
+ };
1303
+ }
1304
+ if (error.code === "invalid_key") {
1305
+ return { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1306
+ }
1307
+ return {
1308
+ title: labels.errorTitle,
1309
+ detail: error.code === "server" ? labels.errorDetail : userMessage(error)
1310
+ };
1311
+ }
1243
1312
  function FeatureList() {
1244
1313
  const { initialFeatureId } = useBoard();
1245
- return /* @__PURE__ */ jsxs6(Fragment, { children: [
1314
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1246
1315
  initialFeatureId && /* @__PURE__ */ jsx9(PinnedFeature, { featureId: initialFeatureId }),
1247
1316
  /* @__PURE__ */ jsx9(Listing, { excludeId: initialFeatureId })
1248
1317
  ] });
1249
1318
  }
1250
1319
  function PinnedFeature({ featureId }) {
1251
1320
  const { cx } = useBoard();
1252
- const { feature, isLoading } = useFeature(featureId);
1321
+ const { feature, isLoading, error } = useFeature(featureId);
1253
1322
  if (isLoading) return /* @__PURE__ */ jsx9(FeatureListSkeleton, { rows: 1 });
1323
+ if (!feature && error) {
1324
+ return /* @__PURE__ */ jsx9("p", { className: cx("fw-error", "fw-pinned"), role: "alert", children: userMessage(error) });
1325
+ }
1254
1326
  if (!feature) return null;
1255
1327
  return /* @__PURE__ */ jsx9("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ jsx9("ul", { className: cx("fw-list"), children: /* @__PURE__ */ jsx9(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
1256
1328
  }
@@ -1269,32 +1341,30 @@ function Listing({ excludeId }) {
1269
1341
  return showDeclined || feature.status !== "declined";
1270
1342
  });
1271
1343
  if (error && visible.length === 0) {
1344
+ const { title, detail } = errorCopy(error, labels);
1272
1345
  return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), role: "alert", children: [
1273
- /* @__PURE__ */ jsx9("p", { children: userMessage(error) }),
1274
- /* @__PURE__ */ jsx9(
1275
- "button",
1276
- {
1277
- type: "button",
1278
- className: cx("fw-btn", "fw-btn--ghost"),
1279
- onClick: () => void refresh(),
1280
- children: labels.retry
1281
- }
1282
- )
1346
+ /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: title }),
1347
+ /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__detail"), children: detail }),
1348
+ !isNotConfigured(error) && /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1283
1349
  ] });
1284
1350
  }
1285
1351
  if (isLoading && visible.length === 0) {
1286
1352
  return /* @__PURE__ */ jsx9(FeatureListSkeleton, { roadmap: isRoadmap });
1287
1353
  }
1288
1354
  if (visible.length === 0) {
1289
- if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment, { children: renderEmpty() });
1355
+ if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment2, { children: renderEmpty() });
1290
1356
  return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), children: [
1291
1357
  /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: labels.emptyTitle }),
1292
1358
  /* @__PURE__ */ jsx9("p", { children: labels.emptyBody })
1293
1359
  ] });
1294
1360
  }
1295
- return /* @__PURE__ */ jsxs6(Fragment, { children: [
1361
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1296
1362
  isRoadmap ? /* @__PURE__ */ jsx9(RoadmapColumns, { features: visible }) : /* @__PURE__ */ jsx9(Rows2, { features: visible }),
1297
- hasMore && /* @__PURE__ */ jsx9("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ jsx9(
1363
+ hasMore && error && /* @__PURE__ */ jsxs6("div", { className: cx("fw-pagination"), role: "alert", children: [
1364
+ /* @__PURE__ */ jsx9("span", { children: labels.errorLoadMore }),
1365
+ /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void loadMore(), children: labels.retry })
1366
+ ] }),
1367
+ hasMore && !error && /* @__PURE__ */ jsx9("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ jsx9(
1298
1368
  "button",
1299
1369
  {
1300
1370
  type: "button",
@@ -1337,7 +1407,7 @@ function FeatureSlot({ feature }) {
1337
1407
  const { renderFeature } = useBoard();
1338
1408
  const { toggle } = useVote(feature.id);
1339
1409
  const { follow, unfollow } = useFollow(feature.id);
1340
- return /* @__PURE__ */ jsx9(Fragment, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1410
+ return /* @__PURE__ */ jsx9(Fragment2, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1341
1411
  }
1342
1412
 
1343
1413
  // src/components/SubmitForm.tsx
@@ -1375,6 +1445,10 @@ function SubmitForm() {
1375
1445
  const [title, setTitle] = useState9("");
1376
1446
  const [description, setDescription] = useState9("");
1377
1447
  const [email, setEmail] = useState9(userEmail ?? "");
1448
+ const isTitleValid = title.trim().length >= LIMITS.titleMin;
1449
+ const isEmailInvalid = emailField !== "off" && email !== "" && !EMAIL_PATTERN.test(email.trim());
1450
+ const isEmailMissing = emailField === "required" && !email.trim();
1451
+ const canSubmit = isTitleValid && !isEmailInvalid && !isEmailMissing;
1378
1452
  return /* @__PURE__ */ jsxs7(
1379
1453
  "form",
1380
1454
  {
@@ -1408,7 +1482,15 @@ function SubmitForm() {
1408
1482
  onChange: (event) => setTitle(event.target.value),
1409
1483
  required: true
1410
1484
  }
1411
- )
1485
+ ),
1486
+ /* @__PURE__ */ jsxs7("div", { className: cx("fw-field__foot"), children: [
1487
+ /* @__PURE__ */ jsx10("span", { className: cx("fw-error"), children: title !== "" && !isTitleValid ? labels.fieldTitleTooShort : "" }),
1488
+ /* @__PURE__ */ jsxs7("span", { className: cx("fw-muted"), children: [
1489
+ title.length,
1490
+ "/",
1491
+ LIMITS.titleMax
1492
+ ] })
1493
+ ] })
1412
1494
  ] }),
1413
1495
  /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
1414
1496
  /* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
@@ -1422,7 +1504,15 @@ function SubmitForm() {
1422
1504
  placeholder: labels.fieldDescriptionPlaceholder,
1423
1505
  onChange: (event) => setDescription(event.target.value)
1424
1506
  }
1425
- )
1507
+ ),
1508
+ /* @__PURE__ */ jsxs7("div", { className: cx("fw-field__foot"), children: [
1509
+ /* @__PURE__ */ jsx10("span", {}),
1510
+ /* @__PURE__ */ jsxs7("span", { className: cx("fw-muted"), children: [
1511
+ description.length,
1512
+ "/",
1513
+ LIMITS.descriptionMax
1514
+ ] })
1515
+ ] })
1426
1516
  ] }),
1427
1517
  emailField !== "off" && /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
1428
1518
  /* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
@@ -1439,11 +1529,11 @@ function SubmitForm() {
1439
1529
  required: emailField === "required"
1440
1530
  }
1441
1531
  ),
1442
- /* @__PURE__ */ jsx10("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1532
+ 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 })
1443
1533
  ] }),
1444
1534
  error && /* @__PURE__ */ jsx10("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1445
1535
  /* @__PURE__ */ jsxs7("div", { className: cx("fw-form__actions"), children: [
1446
- /* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: isPending ? labels.submitting : labels.submit }),
1536
+ /* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !canSubmit, children: isPending ? labels.submitting : labels.submit }),
1447
1537
  /* @__PURE__ */ jsx10(
1448
1538
  "button",
1449
1539
  {
@@ -1481,7 +1571,7 @@ function themeToStyle(theme) {
1481
1571
  }
1482
1572
 
1483
1573
  // src/components/Board.tsx
1484
- import { Fragment as Fragment2, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
1574
+ import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs8 } from "react/jsx-runtime";
1485
1575
  var DEFAULT_TABS = ["top", "new"];
1486
1576
  function BoardRoot({
1487
1577
  tabs = DEFAULT_TABS,
@@ -1568,7 +1658,7 @@ function BoardRoot({
1568
1658
  }
1569
1659
  function DefaultBoard() {
1570
1660
  const { isFormOpen, justSubmitted, labels, renderHeader, cx } = useBoard();
1571
- return /* @__PURE__ */ jsxs8(Fragment2, { children: [
1661
+ return /* @__PURE__ */ jsxs8(Fragment3, { children: [
1572
1662
  renderHeader ? renderHeader() : /* @__PURE__ */ jsx11(BoardHeading, {}),
1573
1663
  /* @__PURE__ */ jsxs8("div", { className: cx("fw-toolbar"), children: [
1574
1664
  /* @__PURE__ */ jsx11(BoardTabs, {}),
@@ -1692,7 +1782,7 @@ function FewchursBoardDialog({
1692
1782
  // src/components/BoardButton.tsx
1693
1783
  import { useCallback as useCallback7, useState as useState11 } from "react";
1694
1784
  import { createPortal } from "react-dom";
1695
- import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
1785
+ import { Fragment as Fragment4, jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
1696
1786
  function launcherClasses(floating) {
1697
1787
  if (!floating) return "fw-launcher";
1698
1788
  const side = floating === "left" ? " fw-launcher--left" : "";
@@ -1709,7 +1799,7 @@ function FewchursButton({
1709
1799
  const [open, setOpen] = useState11(false);
1710
1800
  const close = useCallback7(() => setOpen(false), []);
1711
1801
  const classes = [board.unstyled ? "" : launcherClasses(floating), className].filter(Boolean).join(" ");
1712
- return /* @__PURE__ */ jsxs10(Fragment3, { children: [
1802
+ return /* @__PURE__ */ jsxs10(Fragment4, { children: [
1713
1803
  /* @__PURE__ */ jsx13(
1714
1804
  "button",
1715
1805
  {
@@ -1755,6 +1845,7 @@ export {
1755
1845
  FewchursProvider,
1756
1846
  FollowForm,
1757
1847
  LIMITS,
1848
+ NOT_CONFIGURED_MESSAGE,
1758
1849
  ROADMAP_STATUSES,
1759
1850
  SDK_HEADER,
1760
1851
  SDK_VERSION,