@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.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.6.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";
@@ -797,6 +816,7 @@ function useFewchurs() {
797
816
 
798
817
  // src/components/Board.tsx
799
818
  var import_react15 = require("react");
819
+ var import_react_dom = require("react-dom");
800
820
 
801
821
  // src/hooks/useFewchursConfig.ts
802
822
  var import_react3 = require("react");
@@ -850,11 +870,19 @@ var DEFAULT_LABELS = {
850
870
  loadMore: "Load more",
851
871
  retry: "Try again",
852
872
  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.",
873
+ 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.",
874
+ errorWrongAppTitle: "Invalid API key",
875
+ errorWrongAppDetail: "",
876
+ errorTitle: "Couldn't load requests",
877
+ errorDetail: "Something went wrong. Try again.",
878
+ errorOfflineTitle: "You're offline",
879
+ errorOfflineDetail: "Check your connection and try again.",
880
+ errorNotSetUpTitle: "Fewchurs isn't set up",
881
+ errorNotSetUpDetail: "Pass apiKey to FewchursProvider, or set FEWCHURS_API_KEY on your server.",
882
+ errorLoadMore: "Couldn't load more.",
883
+ errorComments: "Couldn't load comments.",
884
+ fieldTitleTooShort: "At least 3 characters.",
885
+ fieldEmailInvalid: "Enter a valid email address.",
858
886
  comments: "Comments",
859
887
  commentPlaceholder: "Add a comment",
860
888
  postComment: "Post",
@@ -1053,6 +1081,7 @@ function useComments(featureId, { enabled = true } = {}) {
1053
1081
  store.ensureComments(featureId);
1054
1082
  }, [store, featureId, enabled, isReady]);
1055
1083
  const loadMore = (0, import_react9.useCallback)(() => store.loadMoreComments(featureId), [store, featureId]);
1084
+ const refresh = (0, import_react9.useCallback)(() => store.refreshComments(featureId), [store, featureId]);
1056
1085
  const add = (0, import_react9.useCallback)(
1057
1086
  async (text, authorName) => {
1058
1087
  setIsSubmitting(true);
@@ -1073,6 +1102,7 @@ function useComments(featureId, { enabled = true } = {}) {
1073
1102
  submitError,
1074
1103
  hasMore: comments.cursor !== null,
1075
1104
  loadMore,
1105
+ refresh,
1076
1106
  add
1077
1107
  };
1078
1108
  }
@@ -1092,10 +1122,15 @@ function formatDate(iso) {
1092
1122
  var import_jsx_runtime3 = require("react/jsx-runtime");
1093
1123
  function CommentList({ featureId }) {
1094
1124
  const { labels, cx } = useBoard();
1095
- const { comments, isLoading, isLoadingMore, error, hasMore, loadMore } = useComments(featureId);
1125
+ const { comments, isLoading, isLoadingMore, error, hasMore, loadMore, refresh } = useComments(featureId);
1096
1126
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-comments"), children: [
1097
1127
  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) }),
1128
+ error && comments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: cx("fw-empty", "fw-empty--compact"), role: "alert", children: [
1129
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-empty__title"), children: labels.errorComments }),
1130
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-empty__detail"), children: isNotConfigured(error) ? labels.errorNotSetUpTitle : userMessage(error) }),
1131
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1132
+ ] }),
1133
+ error && comments.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1099
1134
  !isLoading && !error && comments.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-muted"), children: labels.noComments }),
1100
1135
  /* @__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
1136
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: cx("fw-comment__author"), children: comment.authorName }),
@@ -1166,7 +1201,7 @@ function CommentForm({ featureId }) {
1166
1201
  }
1167
1202
  )
1168
1203
  ] }),
1169
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting, children: labels.postComment }),
1204
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isSubmitting || !text.trim(), children: labels.postComment }),
1170
1205
  submitError && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(submitError) })
1171
1206
  ]
1172
1207
  }
@@ -1182,20 +1217,24 @@ function FollowForm({ feature }) {
1182
1217
  const [email, setEmail] = (0, import_react11.useState)(userEmail ?? "");
1183
1218
  const [isOpen, setIsOpen] = (0, import_react11.useState)(false);
1184
1219
  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
- );
1220
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1221
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1222
+ "button",
1223
+ {
1224
+ type: "button",
1225
+ className: cx("fw-link"),
1226
+ disabled: isPending,
1227
+ onClick: () => void unfollow(),
1228
+ children: labels.following
1229
+ }
1230
+ ),
1231
+ error && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1232
+ ] });
1195
1233
  }
1196
1234
  if (!isOpen) {
1197
1235
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "button", className: cx("fw-link"), onClick: () => setIsOpen(true), children: labels.follow });
1198
1236
  }
1237
+ const isEmailValid = EMAIL_PATTERN.test(email.trim());
1199
1238
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1200
1239
  "form",
1201
1240
  {
@@ -1219,7 +1258,8 @@ function FollowForm({ feature }) {
1219
1258
  required: true
1220
1259
  }
1221
1260
  ),
1222
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending, children: labels.follow }),
1261
+ email !== "" && !isEmailValid && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), children: labels.fieldEmailInvalid }),
1262
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !isEmailValid, children: labels.follow }),
1223
1263
  error && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) })
1224
1264
  ]
1225
1265
  }
@@ -1262,7 +1302,7 @@ function FeatureCard({
1262
1302
  defaultCommentsOpen = false
1263
1303
  }) {
1264
1304
  const { labels, allowComments, cx } = useBoard();
1265
- const { isPending, toggle } = useVote(feature.id);
1305
+ const { isPending, toggle, error: voteError } = useVote(feature.id);
1266
1306
  const [showComments, setShowComments] = (0, import_react12.useState)(defaultCommentsOpen);
1267
1307
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("li", { className: cx("fw-card"), children: [
1268
1308
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(VoteButton, { feature, isPending, onToggle: () => void toggle() }),
@@ -1290,6 +1330,7 @@ function FeatureCard({
1290
1330
  ),
1291
1331
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FollowForm, { feature })
1292
1332
  ] }),
1333
+ voteError && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(voteError) }),
1293
1334
  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
1335
  ] })
1295
1336
  ] });
@@ -1331,10 +1372,25 @@ function Bar({ width, height }) {
1331
1372
  // src/components/FeatureList.tsx
1332
1373
  var import_jsx_runtime9 = require("react/jsx-runtime");
1333
1374
  function errorCopy(error, labels) {
1334
- if (error.code !== "invalid_key") {
1335
- return { title: labels.errorTitle, detail: labels.errorDetail };
1375
+ if (isNotConfigured(error)) {
1376
+ return { title: labels.errorNotSetUpTitle, detail: labels.errorNotSetUpDetail };
1377
+ }
1378
+ if (error.code === "network") {
1379
+ return { title: labels.errorOfflineTitle, detail: labels.errorOfflineDetail };
1380
+ }
1381
+ if (isWrongApp(error)) {
1382
+ return {
1383
+ title: labels.errorWrongAppTitle,
1384
+ detail: labels.errorWrongAppDetail || userMessage(error)
1385
+ };
1386
+ }
1387
+ if (error.code === "invalid_key") {
1388
+ return { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1336
1389
  }
1337
- return error.status === 403 ? { title: labels.errorWrongAppTitle, detail: labels.errorWrongAppDetail } : { title: labels.errorInvalidKeyTitle, detail: labels.errorInvalidKeyDetail };
1390
+ return {
1391
+ title: labels.errorTitle,
1392
+ detail: error.code === "server" ? labels.errorDetail : userMessage(error)
1393
+ };
1338
1394
  }
1339
1395
  function FeatureList() {
1340
1396
  const { initialFeatureId } = useBoard();
@@ -1345,8 +1401,11 @@ function FeatureList() {
1345
1401
  }
1346
1402
  function PinnedFeature({ featureId }) {
1347
1403
  const { cx } = useBoard();
1348
- const { feature, isLoading } = useFeature(featureId);
1404
+ const { feature, isLoading, error } = useFeature(featureId);
1349
1405
  if (isLoading) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(FeatureListSkeleton, { rows: 1 });
1406
+ if (!feature && error) {
1407
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-error", "fw-pinned"), role: "alert", children: userMessage(error) });
1408
+ }
1350
1409
  if (!feature) return null;
1351
1410
  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
1411
  }
@@ -1369,7 +1428,7 @@ function Listing({ excludeId }) {
1369
1428
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-empty"), role: "alert", children: [
1370
1429
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: cx("fw-empty__title"), children: title }),
1371
1430
  /* @__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 })
1431
+ !isNotConfigured(error) && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void refresh(), children: labels.retry })
1373
1432
  ] });
1374
1433
  }
1375
1434
  if (isLoading && visible.length === 0) {
@@ -1384,7 +1443,11 @@ function Listing({ excludeId }) {
1384
1443
  }
1385
1444
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
1386
1445
  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)(
1446
+ hasMore && error && /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: cx("fw-pagination"), role: "alert", children: [
1447
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { children: labels.errorLoadMore }),
1448
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", className: cx("fw-btn-text"), onClick: () => void loadMore(), children: labels.retry })
1449
+ ] }),
1450
+ hasMore && !error && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: cx("fw-pagination"), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1388
1451
  "button",
1389
1452
  {
1390
1453
  type: "button",
@@ -1460,11 +1523,24 @@ function useSubmitFeature() {
1460
1523
  // src/components/SubmitForm.tsx
1461
1524
  var import_jsx_runtime10 = require("react/jsx-runtime");
1462
1525
  function SubmitForm() {
1463
- const { labels, emailField, userEmail, isSubscriber, setFormOpen, setJustSubmitted, cx } = useBoard();
1526
+ const {
1527
+ labels,
1528
+ emailField,
1529
+ userEmail,
1530
+ isSubscriber,
1531
+ isFormOpen,
1532
+ setFormOpen,
1533
+ setJustSubmitted,
1534
+ cx
1535
+ } = useBoard();
1464
1536
  const { submit, isPending, error } = useSubmitFeature();
1465
1537
  const [title, setTitle] = (0, import_react14.useState)("");
1466
1538
  const [description, setDescription] = (0, import_react14.useState)("");
1467
1539
  const [email, setEmail] = (0, import_react14.useState)(userEmail ?? "");
1540
+ const isTitleValid = title.trim().length >= LIMITS.titleMin;
1541
+ const isEmailInvalid = emailField !== "off" && email !== "" && !EMAIL_PATTERN.test(email.trim());
1542
+ const isEmailMissing = emailField === "required" && !email.trim();
1543
+ const canSubmit = isTitleValid && !isEmailInvalid && !isEmailMissing;
1468
1544
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(
1469
1545
  "form",
1470
1546
  {
@@ -1478,8 +1554,10 @@ function SubmitForm() {
1478
1554
  isSubscriber
1479
1555
  });
1480
1556
  if (!created) return;
1481
- setTitle("");
1482
- setDescription("");
1557
+ if (!isFormOpen) {
1558
+ setTitle("");
1559
+ setDescription("");
1560
+ }
1483
1561
  setJustSubmitted(true);
1484
1562
  setFormOpen(false);
1485
1563
  },
@@ -1498,7 +1576,15 @@ function SubmitForm() {
1498
1576
  onChange: (event) => setTitle(event.target.value),
1499
1577
  required: true
1500
1578
  }
1501
- )
1579
+ ),
1580
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field__foot"), children: [
1581
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className: cx("fw-error"), children: title !== "" && !isTitleValid ? labels.fieldTitleTooShort : "" }),
1582
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: cx("fw-muted"), children: [
1583
+ title.length,
1584
+ "/",
1585
+ LIMITS.titleMax
1586
+ ] })
1587
+ ] })
1502
1588
  ] }),
1503
1589
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1504
1590
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-description", children: labels.fieldDescription }),
@@ -1512,7 +1598,15 @@ function SubmitForm() {
1512
1598
  placeholder: labels.fieldDescriptionPlaceholder,
1513
1599
  onChange: (event) => setDescription(event.target.value)
1514
1600
  }
1515
- )
1601
+ ),
1602
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field__foot"), children: [
1603
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", {}),
1604
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: cx("fw-muted"), children: [
1605
+ description.length,
1606
+ "/",
1607
+ LIMITS.descriptionMax
1608
+ ] })
1609
+ ] })
1516
1610
  ] }),
1517
1611
  emailField !== "off" && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: cx("fw-field"), children: [
1518
1612
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: cx("fw-label"), htmlFor: "fw-email", children: labels.fieldEmail }),
@@ -1529,11 +1623,11 @@ function SubmitForm() {
1529
1623
  required: emailField === "required"
1530
1624
  }
1531
1625
  ),
1532
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { id: "fw-email-hint", className: cx("fw-muted"), children: labels.fieldEmailHint })
1626
+ 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
1627
  ] }),
1534
1628
  error && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("p", { className: cx("fw-error"), role: "alert", children: userMessage(error) }),
1535
1629
  /* @__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 }),
1630
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("button", { type: "submit", className: cx("fw-btn"), disabled: isPending || !canSubmit, children: isPending ? labels.submitting : labels.submit }),
1537
1631
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
1538
1632
  "button",
1539
1633
  {
@@ -1657,19 +1751,92 @@ function BoardRoot({
1657
1751
  ) });
1658
1752
  }
1659
1753
  function DefaultBoard() {
1660
- const { isFormOpen, justSubmitted, labels, renderHeader, cx } = useBoard();
1754
+ const { renderHeader } = useBoard();
1661
1755
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_jsx_runtime11.Fragment, { children: [
1662
1756
  renderHeader ? renderHeader() : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardHeading, {}),
1663
- /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: cx("fw-toolbar"), children: [
1664
- /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardTabs, {}),
1665
- /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitTrigger, {})
1666
- ] }),
1667
- justSubmitted && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
1668
- isFormOpen && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitForm, {}),
1669
- /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(FeatureList, {}),
1757
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardPanels, {}),
1670
1758
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Branding, {})
1671
1759
  ] });
1672
1760
  }
1761
+ var LEAVE_MS = 110;
1762
+ var ENTER_MS = 300;
1763
+ var ENTER_EASING = "cubic-bezier(0.22, 1, 0.36, 1)";
1764
+ function canAnimate(element) {
1765
+ if (!element || typeof element.animate !== "function") return false;
1766
+ return !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
1767
+ }
1768
+ function BoardPanels() {
1769
+ const { isFormOpen, setFormOpen, justSubmitted, setJustSubmitted, labels, unstyled, cx } = useBoard();
1770
+ const [showForm, setShowForm] = (0, import_react15.useState)(isFormOpen);
1771
+ const panel = (0, import_react15.useRef)(null);
1772
+ const trigger = (0, import_react15.useRef)(null);
1773
+ const entering = (0, import_react15.useRef)(null);
1774
+ const hasSwapped = (0, import_react15.useRef)(false);
1775
+ (0, import_react15.useEffect)(() => {
1776
+ if (isFormOpen === showForm) return;
1777
+ hasSwapped.current = true;
1778
+ const element = panel.current;
1779
+ if (unstyled || !canAnimate(element)) {
1780
+ setShowForm(isFormOpen);
1781
+ return;
1782
+ }
1783
+ entering.current?.cancel();
1784
+ const leaving = element.animate(
1785
+ { opacity: [1, 0], transform: ["translateY(0)", "translateY(-4px)"] },
1786
+ { duration: LEAVE_MS, easing: "ease-in", fill: "forwards" }
1787
+ );
1788
+ leaving.onfinish = () => {
1789
+ const from = element.offsetHeight;
1790
+ (0, import_react_dom.flushSync)(() => setShowForm(isFormOpen));
1791
+ leaving.cancel();
1792
+ const to = element.offsetHeight;
1793
+ element.style.overflow = "hidden";
1794
+ const enter = element.animate(
1795
+ {
1796
+ opacity: [0, 1],
1797
+ transform: ["translateY(6px)", "translateY(0)"],
1798
+ height: [`${from}px`, `${to}px`]
1799
+ },
1800
+ { duration: ENTER_MS, easing: ENTER_EASING }
1801
+ );
1802
+ const finish = () => {
1803
+ element.style.overflow = "";
1804
+ };
1805
+ enter.onfinish = finish;
1806
+ enter.oncancel = finish;
1807
+ entering.current = enter;
1808
+ };
1809
+ return () => leaving.cancel();
1810
+ }, [isFormOpen, showForm, unstyled]);
1811
+ (0, import_react15.useEffect)(() => {
1812
+ if (!hasSwapped.current) return;
1813
+ const target = showForm ? panel.current?.querySelector("#fw-title") : trigger.current;
1814
+ target?.focus({ preventScroll: true });
1815
+ }, [showForm]);
1816
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { ref: panel, children: [
1817
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { hidden: showForm, children: [
1818
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: cx("fw-toolbar"), children: [
1819
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(BoardTabs, {}),
1820
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
1821
+ "button",
1822
+ {
1823
+ ref: trigger,
1824
+ type: "button",
1825
+ className: cx("fw-btn"),
1826
+ onClick: () => {
1827
+ setJustSubmitted(false);
1828
+ setFormOpen(true);
1829
+ },
1830
+ children: labels.newRequest
1831
+ }
1832
+ )
1833
+ ] }),
1834
+ justSubmitted && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: cx("fw-alert"), role: "status", children: labels.submitted }),
1835
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(FeatureList, {})
1836
+ ] }),
1837
+ showForm && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SubmitForm, {})
1838
+ ] });
1839
+ }
1673
1840
  function BoardHeading() {
1674
1841
  const { labels, cx } = useBoard();
1675
1842
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("h2", { className: cx("fw-heading"), children: labels.title });
@@ -1781,7 +1948,7 @@ function FewchursBoardDialog({
1781
1948
 
1782
1949
  // src/components/BoardButton.tsx
1783
1950
  var import_react17 = require("react");
1784
- var import_react_dom = require("react-dom");
1951
+ var import_react_dom2 = require("react-dom");
1785
1952
  var import_jsx_runtime13 = require("react/jsx-runtime");
1786
1953
  function launcherClasses(floating) {
1787
1954
  if (!floating) return "fw-launcher";
@@ -1812,7 +1979,7 @@ function FewchursButton({
1812
1979
  children
1813
1980
  }
1814
1981
  ),
1815
- open ? (0, import_react_dom.createPortal)(
1982
+ open ? (0, import_react_dom2.createPortal)(
1816
1983
  /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
1817
1984
  FewchursBoardDialog,
1818
1985
  {
@@ -1846,6 +2013,7 @@ function FewchursButton({
1846
2013
  FewchursProvider,
1847
2014
  FollowForm,
1848
2015
  LIMITS,
2016
+ NOT_CONFIGURED_MESSAGE,
1849
2017
  ROADMAP_STATUSES,
1850
2018
  SDK_HEADER,
1851
2019
  SDK_VERSION,