@fewchurs/react 1.4.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.js CHANGED
@@ -38,6 +38,7 @@ __export(src_exports, {
38
38
  FewchursProvider: () => FewchursProvider,
39
39
  FollowForm: () => FollowForm,
40
40
  LIMITS: () => LIMITS,
41
+ NOT_CONFIGURED_MESSAGE: () => NOT_CONFIGURED_MESSAGE,
41
42
  ROADMAP_STATUSES: () => ROADMAP_STATUSES,
42
43
  SDK_HEADER: () => SDK_HEADER,
43
44
  SDK_VERSION: () => SDK_VERSION,
@@ -85,7 +86,7 @@ module.exports = __toCommonJS(src_exports);
85
86
  var import_react = require("react");
86
87
 
87
88
  // src/client/version.ts
88
- var SDK_VERSION = "1.4.0";
89
+ var SDK_VERSION = "1.5.0";
89
90
 
90
91
  // src/client/constants.ts
91
92
  var SDK_HEADER = `react/${SDK_VERSION}`;
@@ -103,17 +104,28 @@ var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
103
104
  var ROADMAP_STATUSES = ["planned", "in_progress", "done"];
104
105
 
105
106
  // src/client/errors.ts
107
+ var NOT_CONFIGURED_MESSAGE = "Fewchurs is not configured.";
106
108
  var USER_MESSAGES = {
107
- invalid_key: "Feedback is unavailable right now.",
108
- validation_failed: "Please check what you entered and try again.",
109
- rate_limited: "You have reached today's limit. Try again tomorrow.",
110
- not_found: "That request no longer exists.",
111
- limit_reached: "Feedback is unavailable right now.",
112
- server: "Something went wrong. Please try again.",
113
- network: "Could not reach the server. Check your connection."
109
+ invalid_key: "Invalid API key. Check the key in your Fewchurs dashboard.",
110
+ validation_failed: "Invalid input.",
111
+ rate_limited: "You've reached today's limit. Try again tomorrow.",
112
+ not_found: "This request no longer exists.",
113
+ limit_reached: "This board isn't accepting requests right now.",
114
+ server: "Something went wrong. Try again.",
115
+ network: "You're offline."
114
116
  };
117
+ function isWrongApp(error) {
118
+ return error.code === "invalid_key" && error.status === 403;
119
+ }
120
+ function isNotConfigured(error) {
121
+ return error.code === "server" && error.message === NOT_CONFIGURED_MESSAGE;
122
+ }
123
+ function notConfiguredError() {
124
+ return { code: "server", status: 500, message: NOT_CONFIGURED_MESSAGE };
125
+ }
115
126
  function userMessage(error) {
116
127
  if (error.code === "validation_failed" && error.message) return error.message;
128
+ if (isWrongApp(error) && error.message) return error.message;
117
129
  return USER_MESSAGES[error.code];
118
130
  }
119
131
  function networkError(message = "Network request failed.") {
@@ -124,17 +136,17 @@ function validationError(message) {
124
136
  }
125
137
  function errorFromResponse(status, body) {
126
138
  const code = body?.error?.code;
127
- const message = body?.error?.message ?? `Request failed (${status}).`;
139
+ const serverMessage = body?.error?.message;
140
+ const message = serverMessage ?? `Request failed (${status}).`;
128
141
  switch (code) {
129
142
  case "invalid_key":
130
143
  return { code: "invalid_key", status: 401, message };
131
144
  case "wrong_app":
132
145
  return { code: "invalid_key", status: 403, message };
133
- // missing_device_id is a client mistake the caller fixes the same way a bad field is fixed,
134
- // so it collapses into validation_failed rather than widening the public union.
135
146
  case "missing_device_id":
147
+ return { code: "server", status: 400, message };
136
148
  case "validation_failed":
137
- return { code: "validation_failed", status: 400, message };
149
+ return { code: "validation_failed", status: 400, message: serverMessage ?? "Invalid input." };
138
150
  case "rate_limited":
139
151
  return { code: "rate_limited", status: 429, message };
140
152
  case "not_found":
@@ -228,11 +240,8 @@ function normalizeVote(value) {
228
240
  // src/client/validate.ts
229
241
  function validateFeatureInput(input) {
230
242
  const title = input.title.trim();
231
- if (title.length < LIMITS.titleMin) {
232
- return validationError(`Title must be at least ${LIMITS.titleMin} characters.`);
233
- }
234
- if (title.length > LIMITS.titleMax) {
235
- return validationError(`Title must be at most ${LIMITS.titleMax} characters.`);
243
+ if (title.length < LIMITS.titleMin || title.length > LIMITS.titleMax) {
244
+ return validationError(`Title must be ${LIMITS.titleMin}\u2013${LIMITS.titleMax} characters.`);
236
245
  }
237
246
  if ((input.description ?? "").trim().length > LIMITS.descriptionMax) {
238
247
  return validationError(`Description must be at most ${LIMITS.descriptionMax} characters.`);
@@ -244,9 +253,8 @@ function validateFeatureInput(input) {
244
253
  }
245
254
  function validateCommentInput(input) {
246
255
  const text = input.text.trim();
247
- if (text.length < LIMITS.commentMin) return validationError("Write a comment first.");
248
- if (text.length > LIMITS.commentMax) {
249
- return validationError(`Comment must be at most ${LIMITS.commentMax} characters.`);
256
+ if (text.length < LIMITS.commentMin || text.length > LIMITS.commentMax) {
257
+ return validationError(`Comment must be ${LIMITS.commentMin}\u2013${LIMITS.commentMax} characters.`);
250
258
  }
251
259
  if ((input.authorName ?? "").trim().length > LIMITS.authorNameMax) {
252
260
  return validationError(`Name must be at most ${LIMITS.authorNameMax} characters.`);
@@ -266,6 +274,7 @@ function createFewchursClient(options = {}) {
266
274
  const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
267
275
  const retries = options.retries ?? 1;
268
276
  const retryDelayMs = options.retryDelayMs ?? 250;
277
+ if (options.apiKey && !options.apiKey.startsWith("fr_")) logOnce(BAD_KEY_PREFIX);
269
278
  function getDeviceId() {
270
279
  return typeof options.deviceId === "function" ? options.deviceId() : options.deviceId ?? null;
271
280
  }
@@ -292,6 +301,11 @@ function createFewchursClient(options = {}) {
292
301
  }
293
302
  async function request(path, map, { method = "GET", body, query, signal } = {}) {
294
303
  if (!doFetch) return { ok: false, error: networkError("No fetch implementation available.") };
304
+ if (!options.apiKey && options.baseUrl === void 0) {
305
+ const failure = notConfiguredError();
306
+ logError(failure);
307
+ return { ok: false, error: failure };
308
+ }
295
309
  const target = url(path, query);
296
310
  const init = { method, headers: headers(body !== void 0) };
297
311
  if (body !== void 0) init.body = JSON.stringify(body);
@@ -411,16 +425,21 @@ function delay(ms) {
411
425
  function messageOf(error) {
412
426
  return error instanceof Error ? error.message : "Network request failed.";
413
427
  }
414
- 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.";
428
+ var INVALID_KEY_HELP = "Fewchurs rejected the API key. Check the key in your dashboard (Apps \u2192 API keys).";
429
+ var NOT_CONFIGURED_HELP = "Fewchurs is not configured. Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.";
430
+ var BAD_KEY_PREFIX = 'The Fewchurs API key should start with "fr_". Copy it from your dashboard.';
415
431
  var loggedErrors = /* @__PURE__ */ new Set();
416
- function logError(error) {
432
+ function logOnce(detail) {
417
433
  if (typeof process !== "undefined" && process.env?.NODE_ENV === "production") return;
418
- const isInvalidKey = error.code === "invalid_key" && error.status === 401;
419
- const detail = isInvalidKey ? INVALID_KEY_HELP : error.message;
420
434
  if (loggedErrors.has(detail)) return;
421
435
  loggedErrors.add(detail);
422
436
  console.warn(`[fewchurs] : ${detail}`);
423
437
  }
438
+ function logError(error) {
439
+ if (isNotConfigured(error)) return logOnce(NOT_CONFIGURED_HELP);
440
+ const isInvalidKey = error.code === "invalid_key" && error.status === 401;
441
+ logOnce(isInvalidKey ? INVALID_KEY_HELP : error.message);
442
+ }
424
443
 
425
444
  // src/identity/deviceId.ts
426
445
  var COOKIE_NAME = "fewchurs_did";
@@ -850,11 +869,19 @@ var DEFAULT_LABELS = {
850
869
  loadMore: "Load more",
851
870
  retry: "Try again",
852
871
  errorInvalidKeyTitle: "Invalid API key",
853
- errorInvalidKeyDetail: "We can't connect to this board right now. The owner of this site needs to check its setup.",
854
- errorWrongAppTitle: "Wrong platform",
855
- errorWrongAppDetail: "This board isn't set up for this site or app yet. Its owner needs to add it in Fewchurs.",
856
- errorTitle: "Something went wrong",
857
- errorDetail: "We couldn't load the requests. Please try again.",
872
+ 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.",
873
+ errorWrongAppTitle: "Invalid API key",
874
+ errorWrongAppDetail: "",
875
+ errorTitle: "Couldn't load requests",
876
+ errorDetail: "Something went wrong. Try again.",
877
+ errorOfflineTitle: "You're offline",
878
+ errorOfflineDetail: "Check your connection and try again.",
879
+ errorNotSetUpTitle: "Fewchurs isn't set up",
880
+ errorNotSetUpDetail: "Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
881
+ errorLoadMore: "Couldn't load more.",
882
+ errorComments: "Couldn't load comments.",
883
+ fieldTitleTooShort: "At least 3 characters.",
884
+ fieldEmailInvalid: "Enter a valid email address.",
858
885
  comments: "Comments",
859
886
  commentPlaceholder: "Add a comment",
860
887
  postComment: "Post",
@@ -1053,6 +1080,7 @@ function useComments(featureId, { enabled = true } = {}) {
1053
1080
  store.ensureComments(featureId);
1054
1081
  }, [store, featureId, enabled, isReady]);
1055
1082
  const loadMore = (0, import_react9.useCallback)(() => store.loadMoreComments(featureId), [store, featureId]);
1083
+ const refresh = (0, import_react9.useCallback)(() => store.refreshComments(featureId), [store, featureId]);
1056
1084
  const add = (0, import_react9.useCallback)(
1057
1085
  async (text, authorName) => {
1058
1086
  setIsSubmitting(true);
@@ -1073,6 +1101,7 @@ function useComments(featureId, { enabled = true } = {}) {
1073
1101
  submitError,
1074
1102
  hasMore: comments.cursor !== null,
1075
1103
  loadMore,
1104
+ refresh,
1076
1105
  add
1077
1106
  };
1078
1107
  }
@@ -1092,10 +1121,15 @@ function formatDate(iso) {
1092
1121
  var import_jsx_runtime3 = require("react/jsx-runtime");
1093
1122
  function CommentList({ featureId }) {
1094
1123
  const { labels, cx } = useBoard();
1095
- const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
1124
+ const { comments, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useComments(featureId);
1096
1125
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-comments"), children: [
1097
1126
  isLoading && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.loading }),
1098
- error && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1127
+ error && comments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-empty", "fw-empty--compact"), role: "alert", children: [
1128
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-empty__title"), children: labels.errorComments }),
1129
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-empty__detail"), children: isNotConfigured(error) ? labels.errorNotSetUpTitle : userMessage(error) }),
1130
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1131
+ ] }),
1132
+ error && comments.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1099
1133
  !isLoading && !error && comments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.noComments }),
1100
1134
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("li", { className: cx("fw-comment"), children: [
1101
1135
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("fw-comment__author"), children: comment.authorName }),
@@ -1166,7 +1200,7 @@ function CommentForm({ featureId }) {
1166
1200
  }
1167
1201
  )
1168
1202
  ] }),
1169
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
1203
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting || !text.trim(), children: labels.postComment }),
1170
1204
  submitError && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
1171
1205
  ]
1172
1206
  }
@@ -1182,20 +1216,24 @@ function FollowForm({ feature }) {
1182
1216
  const [email, setEmail] = (0, import_react11.useState)(userEmail ?? "");
1183
1217
  const [isOpen, setIsOpen] = (0, import_react11.useState)(false);
1184
1218
  if (isFollowing) {
1185
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1186
- "button",
1187
- {
1188
- type: "button",
1189
- className: cx("fw-link"),
1190
- disabled: isPending,
1191
- onClick: () => void unfollow(),
1192
- children: labels.following
1193
- }
1194
- );
1219
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1220
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1221
+ "button",
1222
+ {
1223
+ type: "button",
1224
+ className: cx("fw-link"),
1225
+ disabled: isPending,
1226
+ onClick: () => void unfollow(),
1227
+ children: labels.following
1228
+ }
1229
+ ),
1230
+ error && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1231
+ ] });
1195
1232
  }
1196
1233
  if (!isOpen) {
1197
1234
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
1198
1235
  }
1236
+ const isEmailValid = EMAIL_PATTERN.test(email.trim());
1199
1237
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1200
1238
  "form",
1201
1239
  {
@@ -1219,7 +1257,8 @@ function FollowForm({ feature }) {
1219
1257
  required: true
1220
1258
  }
1221
1259
  ),
1222
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: labels.follow }),
1260
+ email !== "" && !isEmailValid && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), children: labels.fieldEmailInvalid }),
1261
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !isEmailValid, children: labels.follow }),
1223
1262
  error && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1224
1263
  ]
1225
1264
  }
@@ -1262,7 +1301,7 @@ function FeatureCard({
1262
1301
  defaultCommentsOpen = false
1263
1302
  }) {
1264
1303
  const { labels, allowComments, cx } = useBoard();
1265
- const { isPending, toggle } = useVote(feature.id);
1304
+ const { isPending, toggle, error: voteError } = useVote(feature.id);
1266
1305
  const [showComments, setShowComments] = (0, import_react12.useState)(defaultCommentsOpen);
1267
1306
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("li", { className: cx("fw-card"), children: [
1268
1307
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
@@ -1290,6 +1329,7 @@ function FeatureCard({
1290
1329
  ),
1291
1330
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FollowForm, { feature })
1292
1331
  ] }),
1332
+ voteError && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(voteError) }),
1293
1333
  allowComments && showComments && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(CommentList, { featureId: feature.id }) })
1294
1334
  ] })
1295
1335
  ] });
@@ -1331,10 +1371,25 @@ function Bar({ width, height }) {
1331
1371
  // src/components/FeatureList.tsx
1332
1372
  var import_jsx_runtime9 = require("react/jsx-runtime");
1333
1373
  function errorCopy(error, labels) {
1334
- if (error.code !== "invalid_key") {
1335
- return { title: labels.errorTitle, detail: labels.errorDetail };
1374
+ if (isNotConfigured(error)) {
1375
+ return { title: labels.errorNotSetUpTitle, detail: labels.errorNotSetUpDetail };
1376
+ }
1377
+ if (error.code === "network") {
1378
+ return { title: labels.errorOfflineTitle, detail: labels.errorOfflineDetail };
1379
+ }
1380
+ if (isWrongApp(error)) {
1381
+ return {
1382
+ title: labels.errorWrongAppTitle,
1383
+ detail: labels.errorWrongAppDetail || userMessage(error)
1384
+ };
1385
+ }
1386
+ if (error.code === "invalid_key") {
1387
+ return { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1336
1388
  }
1337
- return error.status === 403 ? { title: labels.errorWrongAppTitle, detail: labels.errorWrongAppDetail } : { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1389
+ return {
1390
+ title: labels.errorTitle,
1391
+ detail: error.code === "server" ? labels.errorDetail : userMessage(error)
1392
+ };
1338
1393
  }
1339
1394
  function FeatureList() {
1340
1395
  const { initialFeatureId } = useBoard();
@@ -1345,8 +1400,11 @@ function FeatureList() {
1345
1400
  }
1346
1401
  function PinnedFeature({ featureId }) {
1347
1402
  const { cx } = useBoard();
1348
- const { feature, isLoading } = useFeature(featureId);
1403
+ const { feature, isLoading, error } = useFeature(featureId);
1349
1404
  if (isLoading) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureListSkeleton, { rows: 1 });
1405
+ if (!feature && error) {
1406
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-error", "fw-pinned"), role: "alert", children: userMessage(error) });
1407
+ }
1350
1408
  if (!feature) return null;
1351
1409
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("ul", { className: cx("fw-list"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
1352
1410
  }
@@ -1369,7 +1427,7 @@ function Listing({ excludeId }) {
1369
1427
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-empty"), role: "alert", children: [
1370
1428
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-empty__title"), children: title }),
1371
1429
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-empty__detail"), children: detail }),
1372
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1430
+ !isNotConfigured(error) && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1373
1431
  ] });
1374
1432
  }
1375
1433
  if (isLoading && visible.length === 0) {
@@ -1384,7 +1442,11 @@ function Listing({ excludeId }) {
1384
1442
  }
1385
1443
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
1386
1444
  isRoadmap ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(RoadmapColumns, { features: visible }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Rows2, { features: visible }),
1387
- hasMore && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1445
+ hasMore && error && /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-pagination"), role: "alert", children: [
1446
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { children: labels.errorLoadMore }),
1447
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void loadMore(), children: labels.retry })
1448
+ ] }),
1449
+ hasMore && !error && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1388
1450
  "button",
1389
1451
  {
1390
1452
  type: "button",
@@ -1465,6 +1527,10 @@ function SubmitForm() {
1465
1527
  const [title, setTitle] = (0, import_react14.useState)("");
1466
1528
  const [description, setDescription] = (0, import_react14.useState)("");
1467
1529
  const [email, setEmail] = (0, import_react14.useState)(userEmail ?? "");
1530
+ const isTitleValid = title.trim().length >= LIMITS.titleMin;
1531
+ const isEmailInvalid = emailField !== "off" && email !== "" && !EMAIL_PATTERN.test(email.trim());
1532
+ const isEmailMissing = emailField === "required" && !email.trim();
1533
+ const canSubmit = isTitleValid && !isEmailInvalid && !isEmailMissing;
1468
1534
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1469
1535
  "form",
1470
1536
  {
@@ -1498,7 +1564,15 @@ function SubmitForm() {
1498
1564
  onChange: (event) => setTitle(event.target.value),
1499
1565
  required: true
1500
1566
  }
1501
- )
1567
+ ),
1568
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field__foot"), children: [
1569
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className: cx("fw-error"), children: title !== "" && !isTitleValid ? labels.fieldTitleTooShort : "" }),
1570
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: cx("fw-muted"), children: [
1571
+ title.length,
1572
+ "/",
1573
+ LIMITS.titleMax
1574
+ ] })
1575
+ ] })
1502
1576
  ] }),
1503
1577
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1504
1578
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
@@ -1512,7 +1586,15 @@ function SubmitForm() {
1512
1586
  placeholder: labels.fieldDescriptionPlaceholder,
1513
1587
  onChange: (event) => setDescription(event.target.value)
1514
1588
  }
1515
- )
1589
+ ),
1590
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field__foot"), children: [
1591
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", {}),
1592
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: cx("fw-muted"), children: [
1593
+ description.length,
1594
+ "/",
1595
+ LIMITS.descriptionMax
1596
+ ] })
1597
+ ] })
1516
1598
  ] }),
1517
1599
  emailField !== "off" && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1518
1600
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
@@ -1529,11 +1611,11 @@ function SubmitForm() {
1529
1611
  required: emailField === "required"
1530
1612
  }
1531
1613
  ),
1532
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1614
+ isEmailInvalid ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { id: "fw-email-hint", className: cx("fw-error"), children: labels.fieldEmailInvalid }) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1533
1615
  ] }),
1534
1616
  error && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1535
1617
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-form__actions"), children: [
1536
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: isPending ? labels.submitting : labels.submit }),
1618
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !canSubmit, children: isPending ? labels.submitting : labels.submit }),
1537
1619
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1538
1620
  "button",
1539
1621
  {
@@ -1846,6 +1928,7 @@ function FewchursButton({
1846
1928
  FewchursProvider,
1847
1929
  FollowForm,
1848
1930
  LIMITS,
1931
+ NOT_CONFIGURED_MESSAGE,
1849
1932
  ROADMAP_STATUSES,
1850
1933
  SDK_HEADER,
1851
1934
  SDK_VERSION,