@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.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.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);
@@ -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";
@@ -769,11 +787,19 @@ var DEFAULT_LABELS = {
769
787
  loadMore: "Load more",
770
788
  retry: "Try again",
771
789
  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.",
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.",
777
803
  comments: "Comments",
778
804
  commentPlaceholder: "Add a comment",
779
805
  postComment: "Post",
@@ -972,6 +998,7 @@ function useComments(featureId, { enabled = true } = {}) {
972
998
  store.ensureComments(featureId);
973
999
  }, [store, featureId, enabled, isReady]);
974
1000
  const loadMore = useCallback5(() => store.loadMoreComments(featureId), [store, featureId]);
1001
+ const refresh = useCallback5(() => store.refreshComments(featureId), [store, featureId]);
975
1002
  const add = useCallback5(
976
1003
  async (text, authorName) => {
977
1004
  setIsSubmitting(true);
@@ -992,6 +1019,7 @@ function useComments(featureId, { enabled = true } = {}) {
992
1019
  submitError,
993
1020
  hasMore: comments.cursor !== null,
994
1021
  loadMore,
1022
+ refresh,
995
1023
  add
996
1024
  };
997
1025
  }
@@ -1011,10 +1039,15 @@ function formatDate(iso) {
1011
1039
  import { jsx as jsx3, jsxs } from "react/jsx-runtime";
1012
1040
  function CommentList({ featureId }) {
1013
1041
  const { labels, cx } = useBoard();
1014
- const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
1042
+ const { comments, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useComments(featureId);
1015
1043
  return /* @__PURE__ */ jsxs("div", { className: cx("fw-comments"), children: [
1016
1044
  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) }),
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) }),
1018
1051
  !isLoading && !error && comments.length === 0 && /* @__PURE__ */ jsx3("p", { className: cx("fw-muted"), children: labels.noComments }),
1019
1052
  /* @__PURE__ */ jsx3("ul", { className: cx("fw-comment-list"), children: comments.map((comment) => /* @__PURE__ */ jsxs("li", { className: cx("fw-comment"), children: [
1020
1053
  /* @__PURE__ */ jsx3("span", { className: cx("fw-comment__author"), children: comment.authorName }),
@@ -1085,7 +1118,7 @@ function CommentForm({ featureId }) {
1085
1118
  }
1086
1119
  )
1087
1120
  ] }),
1088
- /* @__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 }),
1089
1122
  submitError && /* @__PURE__ */ jsx3("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
1090
1123
  ]
1091
1124
  }
@@ -1094,27 +1127,31 @@ function CommentForm({ featureId }) {
1094
1127
 
1095
1128
  // src/components/FollowForm.tsx
1096
1129
  import { useState as useState6 } from "react";
1097
- import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1130
+ import { Fragment, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1098
1131
  function FollowForm({ feature }) {
1099
1132
  const { labels, userEmail, cx } = useBoard();
1100
1133
  const { isFollowing, isPending, error, follow, unfollow } = useFollow(feature.id);
1101
1134
  const [email, setEmail] = useState6(userEmail ?? "");
1102
1135
  const [isOpen, setIsOpen] = useState6(false);
1103
1136
  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
- );
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
+ ] });
1114
1150
  }
1115
1151
  if (!isOpen) {
1116
1152
  return /* @__PURE__ */ jsx4("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
1117
1153
  }
1154
+ const isEmailValid = EMAIL_PATTERN.test(email.trim());
1118
1155
  return /* @__PURE__ */ jsxs2(
1119
1156
  "form",
1120
1157
  {
@@ -1138,7 +1175,8 @@ function FollowForm({ feature }) {
1138
1175
  required: true
1139
1176
  }
1140
1177
  ),
1141
- /* @__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 }),
1142
1180
  error && /* @__PURE__ */ jsx4("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1143
1181
  ]
1144
1182
  }
@@ -1181,7 +1219,7 @@ function FeatureCard({
1181
1219
  defaultCommentsOpen = false
1182
1220
  }) {
1183
1221
  const { labels, allowComments, cx } = useBoard();
1184
- const { isPending, toggle } = useVote(feature.id);
1222
+ const { isPending, toggle, error: voteError } = useVote(feature.id);
1185
1223
  const [showComments, setShowComments] = useState7(defaultCommentsOpen);
1186
1224
  return /* @__PURE__ */ jsxs4("li", { className: cx("fw-card"), children: [
1187
1225
  /* @__PURE__ */ jsx7(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
@@ -1209,6 +1247,7 @@ function FeatureCard({
1209
1247
  ),
1210
1248
  /* @__PURE__ */ jsx7(FollowForm, { feature })
1211
1249
  ] }),
1250
+ voteError && /* @__PURE__ */ jsx7("p", { className: cx("fw-error"), role: "alert", children: userMessage(voteError) }),
1212
1251
  allowComments && showComments && /* @__PURE__ */ jsx7("div", { id: `fw-comments-${feature.id}`, children: /* @__PURE__ */ jsx7(CommentList, { featureId: feature.id }) })
1213
1252
  ] })
1214
1253
  ] });
@@ -1248,24 +1287,42 @@ function Bar({ width, height }) {
1248
1287
  }
1249
1288
 
1250
1289
  // src/components/FeatureList.tsx
1251
- 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";
1252
1291
  function errorCopy(error, labels) {
1253
- if (error.code !== "invalid_key") {
1254
- return { title: labels.errorTitle, detail: labels.errorDetail };
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 };
1255
1306
  }
1256
- return error.status === 403 ? { title: labels.errorWrongAppTitle, detail: labels.errorWrongAppDetail } : { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1307
+ return {
1308
+ title: labels.errorTitle,
1309
+ detail: error.code === "server" ? labels.errorDetail : userMessage(error)
1310
+ };
1257
1311
  }
1258
1312
  function FeatureList() {
1259
1313
  const { initialFeatureId } = useBoard();
1260
- return /* @__PURE__ */ jsxs6(Fragment, { children: [
1314
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1261
1315
  initialFeatureId && /* @__PURE__ */ jsx9(PinnedFeature, { featureId: initialFeatureId }),
1262
1316
  /* @__PURE__ */ jsx9(Listing, { excludeId: initialFeatureId })
1263
1317
  ] });
1264
1318
  }
1265
1319
  function PinnedFeature({ featureId }) {
1266
1320
  const { cx } = useBoard();
1267
- const { feature, isLoading } = useFeature(featureId);
1321
+ const { feature, isLoading, error } = useFeature(featureId);
1268
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
+ }
1269
1326
  if (!feature) return null;
1270
1327
  return /* @__PURE__ */ jsx9("div", { className: cx("fw-pinned"), children: /* @__PURE__ */ jsx9("ul", { className: cx("fw-list"), children: /* @__PURE__ */ jsx9(FeatureCard, { feature, defaultCommentsOpen: true }) }) });
1271
1328
  }
@@ -1288,22 +1345,26 @@ function Listing({ excludeId }) {
1288
1345
  return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), role: "alert", children: [
1289
1346
  /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: title }),
1290
1347
  /* @__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 })
1348
+ !isNotConfigured(error) && /* @__PURE__ */ jsx9("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1292
1349
  ] });
1293
1350
  }
1294
1351
  if (isLoading && visible.length === 0) {
1295
1352
  return /* @__PURE__ */ jsx9(FeatureListSkeleton, { roadmap: isRoadmap });
1296
1353
  }
1297
1354
  if (visible.length === 0) {
1298
- if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment, { children: renderEmpty() });
1355
+ if (renderEmpty) return /* @__PURE__ */ jsx9(Fragment2, { children: renderEmpty() });
1299
1356
  return /* @__PURE__ */ jsxs6("div", { className: cx("fw-empty"), children: [
1300
1357
  /* @__PURE__ */ jsx9("p", { className: cx("fw-empty__title"), children: labels.emptyTitle }),
1301
1358
  /* @__PURE__ */ jsx9("p", { children: labels.emptyBody })
1302
1359
  ] });
1303
1360
  }
1304
- return /* @__PURE__ */ jsxs6(Fragment, { children: [
1361
+ return /* @__PURE__ */ jsxs6(Fragment2, { children: [
1305
1362
  isRoadmap ? /* @__PURE__ */ jsx9(RoadmapColumns, { features: visible }) : /* @__PURE__ */ jsx9(Rows2, { features: visible }),
1306
- 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(
1307
1368
  "button",
1308
1369
  {
1309
1370
  type: "button",
@@ -1346,7 +1407,7 @@ function FeatureSlot({ feature }) {
1346
1407
  const { renderFeature } = useBoard();
1347
1408
  const { toggle } = useVote(feature.id);
1348
1409
  const { follow, unfollow } = useFollow(feature.id);
1349
- return /* @__PURE__ */ jsx9(Fragment, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1410
+ return /* @__PURE__ */ jsx9(Fragment2, { children: renderFeature?.(feature, { toggleVote: toggle, follow, unfollow }) });
1350
1411
  }
1351
1412
 
1352
1413
  // src/components/SubmitForm.tsx
@@ -1384,6 +1445,10 @@ function SubmitForm() {
1384
1445
  const [title, setTitle] = useState9("");
1385
1446
  const [description, setDescription] = useState9("");
1386
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;
1387
1452
  return /* @__PURE__ */ jsxs7(
1388
1453
  "form",
1389
1454
  {
@@ -1417,7 +1482,15 @@ function SubmitForm() {
1417
1482
  onChange: (event) => setTitle(event.target.value),
1418
1483
  required: true
1419
1484
  }
1420
- )
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
+ ] })
1421
1494
  ] }),
1422
1495
  /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
1423
1496
  /* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
@@ -1431,7 +1504,15 @@ function SubmitForm() {
1431
1504
  placeholder: labels.fieldDescriptionPlaceholder,
1432
1505
  onChange: (event) => setDescription(event.target.value)
1433
1506
  }
1434
- )
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
+ ] })
1435
1516
  ] }),
1436
1517
  emailField !== "off" && /* @__PURE__ */ jsxs7("div", { className: cx("fw-field"), children: [
1437
1518
  /* @__PURE__ */ jsx10("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
@@ -1448,11 +1529,11 @@ function SubmitForm() {
1448
1529
  required: emailField === "required"
1449
1530
  }
1450
1531
  ),
1451
- /* @__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 })
1452
1533
  ] }),
1453
1534
  error && /* @__PURE__ */ jsx10("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1454
1535
  /* @__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 }),
1536
+ /* @__PURE__ */ jsx10("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !canSubmit, children: isPending ? labels.submitting : labels.submit }),
1456
1537
  /* @__PURE__ */ jsx10(
1457
1538
  "button",
1458
1539
  {
@@ -1490,7 +1571,7 @@ function themeToStyle(theme) {
1490
1571
  }
1491
1572
 
1492
1573
  // src/components/Board.tsx
1493
- 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";
1494
1575
  var DEFAULT_TABS = ["top", "new"];
1495
1576
  function BoardRoot({
1496
1577
  tabs = DEFAULT_TABS,
@@ -1577,7 +1658,7 @@ function BoardRoot({
1577
1658
  }
1578
1659
  function DefaultBoard() {
1579
1660
  const { isFormOpen, justSubmitted, labels, renderHeader, cx } = useBoard();
1580
- return /* @__PURE__ */ jsxs8(Fragment2, { children: [
1661
+ return /* @__PURE__ */ jsxs8(Fragment3, { children: [
1581
1662
  renderHeader ? renderHeader() : /* @__PURE__ */ jsx11(BoardHeading, {}),
1582
1663
  /* @__PURE__ */ jsxs8("div", { className: cx("fw-toolbar"), children: [
1583
1664
  /* @__PURE__ */ jsx11(BoardTabs, {}),
@@ -1701,7 +1782,7 @@ function FewchursBoardDialog({
1701
1782
  // src/components/BoardButton.tsx
1702
1783
  import { useCallback as useCallback7, useState as useState11 } from "react";
1703
1784
  import { createPortal } from "react-dom";
1704
- 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";
1705
1786
  function launcherClasses(floating) {
1706
1787
  if (!floating) return "fw-launcher";
1707
1788
  const side = floating === "left" ? " fw-launcher--left" : "";
@@ -1718,7 +1799,7 @@ function FewchursButton({
1718
1799
  const [open, setOpen] = useState11(false);
1719
1800
  const close = useCallback7(() => setOpen(false), []);
1720
1801
  const classes = [board.unstyled ? "" : launcherClasses(floating), className].filter(Boolean).join(" ");
1721
- return /* @__PURE__ */ jsxs10(Fragment3, { children: [
1802
+ return /* @__PURE__ */ jsxs10(Fragment4, { children: [
1722
1803
  /* @__PURE__ */ jsx13(
1723
1804
  "button",
1724
1805
  {
@@ -1764,6 +1845,7 @@ export {
1764
1845
  FewchursProvider,
1765
1846
  FollowForm,
1766
1847
  LIMITS,
1848
+ NOT_CONFIGURED_MESSAGE,
1767
1849
  ROADMAP_STATUSES,
1768
1850
  SDK_HEADER,
1769
1851
  SDK_VERSION,