@fewchurs/react 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,523 @@
1
+ import { ReactNode, CSSProperties } from 'react';
2
+
3
+ type FewchursError = {
4
+ code: "invalid_key";
5
+ status: 401;
6
+ message: string;
7
+ } | {
8
+ code: "validation_failed";
9
+ status: 400;
10
+ message: string;
11
+ } | {
12
+ code: "rate_limited";
13
+ status: 429;
14
+ message: string;
15
+ } | {
16
+ code: "not_found";
17
+ status: 404;
18
+ message: string;
19
+ } | {
20
+ code: "limit_reached";
21
+ status: 402;
22
+ message: string;
23
+ } | {
24
+ code: "server";
25
+ status: number;
26
+ message: string;
27
+ } | {
28
+ code: "network";
29
+ message: string;
30
+ };
31
+ type Result<T> = {
32
+ ok: true;
33
+ data: T;
34
+ } | {
35
+ ok: false;
36
+ error: FewchursError;
37
+ };
38
+ /** Safe to show a visitor: never leaks a server message that may name internals. */
39
+ declare function userMessage(error: FewchursError): string;
40
+ declare function networkError(message?: string): FewchursError;
41
+ declare function validationError(message: string): FewchursError;
42
+ declare function errorFromResponse(status: number, body: unknown): FewchursError;
43
+
44
+ /** A status the server does not know about yet still renders, as `"unknown"`. */
45
+ type FeatureStatus = "open" | "planned" | "in_progress" | "done" | "declined" | "unknown";
46
+ type FeatureSort = "top" | "new";
47
+ interface Feature {
48
+ id: string;
49
+ title: string;
50
+ description: string;
51
+ status: FeatureStatus;
52
+ upvoteCount: number;
53
+ commentCount: number;
54
+ followerCount: number;
55
+ hasVoted: boolean;
56
+ isFollowing: boolean;
57
+ isMine: boolean;
58
+ createdAt: string;
59
+ updatedAt: string;
60
+ }
61
+ interface Comment {
62
+ id: string;
63
+ text: string;
64
+ authorName: string;
65
+ isDeveloper: boolean;
66
+ isMine: boolean;
67
+ createdAt: string;
68
+ }
69
+ interface FeaturePage {
70
+ features: Feature[];
71
+ nextCursor: string | null;
72
+ }
73
+ interface CommentPage {
74
+ comments: Comment[];
75
+ nextCursor: string | null;
76
+ }
77
+ interface FewchursConfig {
78
+ showBranding: boolean;
79
+ hideVoteCounts: boolean;
80
+ appName: string | null;
81
+ }
82
+ interface SubmitFeatureInput {
83
+ title: string;
84
+ description?: string;
85
+ email?: string;
86
+ isSubscriber?: boolean;
87
+ }
88
+ interface CreateCommentInput {
89
+ featureId: string;
90
+ text: string;
91
+ authorName?: string;
92
+ }
93
+ interface ListFeaturesParams {
94
+ sort?: FeatureSort;
95
+ limit?: number;
96
+ cursor?: string | null;
97
+ status?: FeatureStatus[];
98
+ }
99
+ interface ListCommentsParams {
100
+ limit?: number;
101
+ cursor?: string | null;
102
+ }
103
+
104
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
105
+ interface FewchursClientOptions {
106
+ /** Server-side, or in a SPA where the key is publishable. Omit when calling your own proxy. */
107
+ apiKey?: string;
108
+ /** `https://www.fewchurs.com/api/v1` by default, or a proxy path such as `/api/fewchurs`. */
109
+ baseUrl?: string;
110
+ deviceId?: string | null | (() => string | null);
111
+ /** Sent as `Accept-Language`, so server-side copy and emails match the visitor. */
112
+ language?: string;
113
+ fetch?: FetchLike;
114
+ retries?: number;
115
+ retryDelayMs?: number;
116
+ }
117
+ interface FewchursClient {
118
+ readonly baseUrl: string;
119
+ getDeviceId(): string | null;
120
+ getConfig(signal?: AbortSignal): Promise<Result<FewchursConfig>>;
121
+ listFeatures(params?: ListFeaturesParams, signal?: AbortSignal): Promise<Result<FeaturePage>>;
122
+ getFeature(featureId: string, signal?: AbortSignal): Promise<Result<Feature>>;
123
+ submitFeature(input: SubmitFeatureInput): Promise<Result<Feature>>;
124
+ vote(featureId: string): Promise<Result<{
125
+ upvoteCount: number;
126
+ hasVoted: boolean;
127
+ }>>;
128
+ unvote(featureId: string): Promise<Result<{
129
+ upvoteCount: number;
130
+ hasVoted: boolean;
131
+ }>>;
132
+ follow(featureId: string, email: string): Promise<Result<{
133
+ following: boolean;
134
+ }>>;
135
+ unfollow(featureId: string): Promise<Result<{
136
+ following: boolean;
137
+ }>>;
138
+ listComments(featureId: string, params?: ListCommentsParams, signal?: AbortSignal): Promise<Result<CommentPage>>;
139
+ createComment(input: CreateCommentInput): Promise<Result<Comment>>;
140
+ }
141
+ declare function createFewchursClient(options?: FewchursClientOptions): FewchursClient;
142
+
143
+ interface ListQuery {
144
+ sort: FeatureSort;
145
+ status?: FeatureStatus[];
146
+ }
147
+ declare function listKey({ sort, status }: ListQuery): string;
148
+ /**
149
+ * One feature, fetched by id, kept in `lists` beside the real lists. That way a deep-linked
150
+ * request goes through the same vote, comment and follow updates as a row in a list.
151
+ */
152
+ declare function featureKey(featureId: string): string;
153
+
154
+ type LoadStatus = "idle" | "loading" | "loadingMore" | "ready" | "error";
155
+ interface ListState {
156
+ features: Feature[];
157
+ cursor: string | null;
158
+ status: LoadStatus;
159
+ error: FewchursError | null;
160
+ }
161
+ interface CommentsState {
162
+ comments: Comment[];
163
+ cursor: string | null;
164
+ status: LoadStatus;
165
+ error: FewchursError | null;
166
+ }
167
+ interface StoreState {
168
+ config: FewchursConfig | null;
169
+ lists: Record<string, ListState>;
170
+ comments: Record<string, CommentsState>;
171
+ }
172
+ type FewchursEventName = "boardOpened" | "featureSubmitted" | "voted" | "commented" | "followed" | "error";
173
+ interface FewchursEvent {
174
+ name: FewchursEventName;
175
+ featureId?: string;
176
+ hasVoted?: boolean;
177
+ following?: boolean;
178
+ error?: FewchursError;
179
+ }
180
+ type EventHandler = (event: FewchursEvent) => void;
181
+ interface StoreOptions {
182
+ pageSize?: number;
183
+ initialState?: Partial<StoreState>;
184
+ onEvent?: EventHandler;
185
+ }
186
+ interface Store {
187
+ subscribe(listener: () => void): () => void;
188
+ getState(): StoreState;
189
+ ensureConfig(): void;
190
+ ensureFeatures(query: ListQuery, pageSize?: number): void;
191
+ ensureFeature(featureId: string): void;
192
+ refreshFeature(featureId: string): Promise<void>;
193
+ refresh(query: ListQuery, pageSize?: number): Promise<void>;
194
+ loadMore(query: ListQuery, pageSize?: number): Promise<void>;
195
+ ensureComments(featureId: string): void;
196
+ refreshComments(featureId: string): Promise<void>;
197
+ loadMoreComments(featureId: string): Promise<void>;
198
+ toggleVote(featureId: string): Promise<Result<{
199
+ upvoteCount: number;
200
+ hasVoted: boolean;
201
+ }>>;
202
+ submitFeature(input: SubmitFeatureInput): Promise<Result<Feature>>;
203
+ addComment(input: CreateCommentInput): Promise<Result<Comment>>;
204
+ setFollowing(featureId: string, email: string | null): Promise<Result<{
205
+ following: boolean;
206
+ }>>;
207
+ emit(event: FewchursEvent): void;
208
+ }
209
+ declare function createStore(client: FewchursClient, { pageSize, initialState, onEvent }?: StoreOptions): Store;
210
+ declare function listStateOf(state: StoreState, key: string): ListState;
211
+ declare function commentsStateOf(state: StoreState, featureId: string): CommentsState;
212
+
213
+ interface FewchursContextValue {
214
+ client: FewchursClient;
215
+ store: Store;
216
+ deviceId: string | null;
217
+ /** False only while a direct-to-API board is still waiting for its device id. */
218
+ isReady: boolean;
219
+ }
220
+ interface FewchursProviderProps {
221
+ /** Publishable key for direct API calls. Omit when `baseUrl` points at your own proxy. */
222
+ apiKey?: string;
223
+ baseUrl?: string;
224
+ /** A stable per-visitor id — hash it if it comes from your auth. */
225
+ deviceId?: string;
226
+ language?: string;
227
+ pageSize?: number;
228
+ /** Server-rendered data, so the first paint has real content. Set by `@fewchurs/next`. */
229
+ initialState?: Partial<StoreState>;
230
+ onEvent?: EventHandler;
231
+ client?: FewchursClient;
232
+ children: ReactNode;
233
+ }
234
+ declare function FewchursProvider({ apiKey, baseUrl, deviceId: deviceIdProp, language, pageSize, initialState, onEvent, client: clientProp, children, }: FewchursProviderProps): React.JSX.Element;
235
+ declare function useFewchurs(): FewchursContextValue;
236
+
237
+ interface BoardLabels {
238
+ title: string;
239
+ tabTop: string;
240
+ tabNew: string;
241
+ tabRoadmap: string;
242
+ newRequest: string;
243
+ cancel: string;
244
+ submit: string;
245
+ submitting: string;
246
+ submitted: string;
247
+ fieldTitle: string;
248
+ fieldTitlePlaceholder: string;
249
+ fieldDescription: string;
250
+ fieldDescriptionPlaceholder: string;
251
+ fieldEmail: string;
252
+ fieldEmailHint: string;
253
+ fieldName: string;
254
+ emptyTitle: string;
255
+ emptyBody: string;
256
+ loading: string;
257
+ loadMore: string;
258
+ retry: string;
259
+ comments: string;
260
+ commentPlaceholder: string;
261
+ postComment: string;
262
+ noComments: string;
263
+ follow: string;
264
+ following: string;
265
+ followPlaceholder: string;
266
+ developer: string;
267
+ poweredBy: string;
268
+ upvote: string;
269
+ status: Record<FeatureStatus, string>;
270
+ }
271
+ declare const DEFAULT_LABELS: BoardLabels;
272
+ declare function mergeLabels(overrides?: Partial<BoardLabels>): BoardLabels;
273
+
274
+ type BoardTab = FeatureSort | "roadmap";
275
+ type EmailFieldMode = "off" | "optional" | "required";
276
+ interface FeatureActions {
277
+ toggleVote: () => Promise<void>;
278
+ follow: (email: string) => Promise<boolean>;
279
+ unfollow: () => Promise<boolean>;
280
+ }
281
+ interface BoardContextValue {
282
+ labels: BoardLabels;
283
+ unstyled: boolean;
284
+ allowComments: boolean;
285
+ emailField: EmailFieldMode;
286
+ showDeclined: boolean;
287
+ hideVoteCounts: boolean;
288
+ pageSize?: number;
289
+ userEmail?: string;
290
+ isSubscriber: boolean;
291
+ tab: BoardTab;
292
+ setTab: (tab: BoardTab) => void;
293
+ tabs: BoardTab[];
294
+ /** A request to show above the list, for a deep link into the board. */
295
+ initialFeatureId?: string;
296
+ isFormOpen: boolean;
297
+ setFormOpen: (open: boolean) => void;
298
+ /** Survives the submit form closing, so the confirmation is not unmounted with it. */
299
+ justSubmitted: boolean;
300
+ setJustSubmitted: (submitted: boolean) => void;
301
+ renderFeature?: (feature: Feature, actions: FeatureActions) => React.ReactNode;
302
+ renderEmpty?: () => React.ReactNode;
303
+ renderHeader?: () => React.ReactNode;
304
+ /** Returns a class name, or undefined in unstyled mode. */
305
+ cx: (...names: string[]) => string | undefined;
306
+ }
307
+ declare function useBoard(): BoardContextValue;
308
+
309
+ declare function Branding(): React.JSX.Element | null;
310
+
311
+ declare const SDK_VERSION = "1.0.1";
312
+
313
+ declare const SDK_HEADER = "react/1.0.1";
314
+ declare const DEFAULT_BASE_URL = "https://www.fewchurs.com/api/v1";
315
+ declare const DEFAULT_PAGE_SIZE = 20;
316
+ /** Mirrors the server's zod schemas, so the UI can reject input before a round trip. */
317
+ declare const LIMITS: {
318
+ readonly titleMin: 3;
319
+ readonly titleMax: 100;
320
+ readonly descriptionMax: 1000;
321
+ readonly commentMin: 1;
322
+ readonly commentMax: 1000;
323
+ readonly authorNameMax: 60;
324
+ };
325
+ /** What the Roadmap tab asks for, and what it keeps if the server ignores the filter. */
326
+ declare const ROADMAP_STATUSES: FeatureStatus[];
327
+
328
+ declare function FeatureList(): React.JSX.Element;
329
+
330
+ declare function SubmitForm(): React.JSX.Element;
331
+
332
+ interface BoardTheme {
333
+ primary?: string;
334
+ /** A number is read as pixels, a string is used as-is (`"0.75rem"`). */
335
+ radius?: number | string;
336
+ surface?: string;
337
+ border?: string;
338
+ text?: string;
339
+ subtle?: string;
340
+ font?: string;
341
+ }
342
+
343
+ interface FewchursBoardProps {
344
+ tabs?: BoardTab[];
345
+ defaultTab?: BoardTab;
346
+ /** Shows this request above the list, for a link straight to one request. */
347
+ initialFeatureId?: string;
348
+ showDeclined?: boolean;
349
+ allowComments?: boolean;
350
+ emailField?: EmailFieldMode;
351
+ pageSize?: number;
352
+ labels?: Partial<BoardLabels>;
353
+ theme?: BoardTheme;
354
+ /** Drops every class name but keeps behaviour, ARIA and keyboard handling. */
355
+ unstyled?: boolean;
356
+ colorScheme?: "light" | "dark" | "system";
357
+ className?: string;
358
+ style?: CSSProperties;
359
+ /** Prefills the submit and follow forms. */
360
+ userEmail?: string;
361
+ isSubscriber?: boolean;
362
+ renderHeader?: () => ReactNode;
363
+ renderEmpty?: () => ReactNode;
364
+ renderFeature?: (feature: Feature, actions: FeatureActions) => ReactNode;
365
+ children?: ReactNode;
366
+ }
367
+ declare function BoardRoot({ tabs, defaultTab, initialFeatureId, showDeclined, allowComments, emailField, pageSize, labels, theme, unstyled, colorScheme, className, style, userEmail, isSubscriber, renderHeader, renderEmpty, renderFeature, children, }: FewchursBoardProps): React.JSX.Element;
368
+ declare function BoardTabs(): React.JSX.Element | null;
369
+ declare function SubmitTrigger(): React.JSX.Element;
370
+ /** Level 1 by default, level 5 when given children to compose. */
371
+ declare const FewchursBoard: typeof BoardRoot & {
372
+ Root: typeof BoardRoot;
373
+ Tabs: typeof BoardTabs;
374
+ List: typeof FeatureList;
375
+ SubmitTrigger: typeof SubmitTrigger;
376
+ SubmitForm: typeof SubmitForm;
377
+ Branding: typeof Branding;
378
+ };
379
+
380
+ interface FewchursBoardDialogProps extends FewchursBoardProps {
381
+ open: boolean;
382
+ onClose: () => void;
383
+ ariaLabel?: string;
384
+ }
385
+ declare function FewchursBoardDialog({ open, onClose, ariaLabel, ...boardProps }: FewchursBoardDialogProps): React.JSX.Element | null;
386
+
387
+ declare function FeatureCard({ feature, defaultCommentsOpen, }: {
388
+ feature: Feature;
389
+ /** Opens the thread straight away, for a board linked to this one request. */
390
+ defaultCommentsOpen?: boolean;
391
+ }): React.JSX.Element;
392
+
393
+ interface VoteButtonProps {
394
+ feature: Feature;
395
+ isPending: boolean;
396
+ onToggle: () => void;
397
+ }
398
+ declare function VoteButton({ feature, isPending, onToggle }: VoteButtonProps): React.JSX.Element;
399
+
400
+ declare function StatusBadge({ status }: {
401
+ status: FeatureStatus;
402
+ }): React.JSX.Element | null;
403
+
404
+ declare function CommentList({ featureId }: {
405
+ featureId: string;
406
+ }): React.JSX.Element;
407
+ declare function CommentForm({ featureId }: {
408
+ featureId: string;
409
+ }): React.JSX.Element;
410
+
411
+ declare function FollowForm({ feature }: {
412
+ feature: Feature;
413
+ }): React.JSX.Element;
414
+
415
+ interface UseFeaturesOptions {
416
+ sort?: FeatureSort;
417
+ status?: FeatureStatus[];
418
+ pageSize?: number;
419
+ enabled?: boolean;
420
+ }
421
+ interface UseFeaturesResult {
422
+ features: Feature[];
423
+ isLoading: boolean;
424
+ isLoadingMore: boolean;
425
+ error: FewchursError | null;
426
+ hasMore: boolean;
427
+ loadMore: () => Promise<void>;
428
+ refresh: () => Promise<void>;
429
+ }
430
+ declare function useFeatures({ sort, status, pageSize, enabled, }?: UseFeaturesOptions): UseFeaturesResult;
431
+
432
+ interface UseFeatureOptions {
433
+ enabled?: boolean;
434
+ }
435
+ interface UseFeatureResult {
436
+ feature: Feature | null;
437
+ isLoading: boolean;
438
+ error: FewchursError | null;
439
+ refresh: () => Promise<void>;
440
+ }
441
+ /**
442
+ * One request by id, for a deep link into a board. The feature lands in the same store the
443
+ * lists use, so voting, commenting and following it behave exactly as they do in a list.
444
+ */
445
+ declare function useFeature(featureId: string | undefined, { enabled }?: UseFeatureOptions): UseFeatureResult;
446
+
447
+ interface UseCommentsOptions {
448
+ enabled?: boolean;
449
+ }
450
+ interface UseCommentsResult {
451
+ comments: Comment[];
452
+ isLoading: boolean;
453
+ isLoadingMore: boolean;
454
+ isSubmitting: boolean;
455
+ error: FewchursError | null;
456
+ submitError: FewchursError | null;
457
+ hasMore: boolean;
458
+ loadMore: () => Promise<void>;
459
+ add: (text: string, authorName?: string) => Promise<boolean>;
460
+ }
461
+ declare function useComments(featureId: string, { enabled }?: UseCommentsOptions): UseCommentsResult;
462
+
463
+ interface UseVoteResult {
464
+ hasVoted: boolean;
465
+ upvoteCount: number;
466
+ isPending: boolean;
467
+ error: FewchursError | null;
468
+ toggle: () => Promise<void>;
469
+ vote: () => Promise<void>;
470
+ unvote: () => Promise<void>;
471
+ }
472
+ declare function useVote(featureId: string): UseVoteResult;
473
+
474
+ interface UseSubmitFeatureResult {
475
+ submit: (input: SubmitFeatureInput) => Promise<Feature | null>;
476
+ isPending: boolean;
477
+ error: FewchursError | null;
478
+ reset: () => void;
479
+ }
480
+ declare function useSubmitFeature(): UseSubmitFeatureResult;
481
+
482
+ interface UseFollowResult {
483
+ isFollowing: boolean;
484
+ isPending: boolean;
485
+ error: FewchursError | null;
486
+ follow: (email: string) => Promise<boolean>;
487
+ unfollow: () => Promise<boolean>;
488
+ }
489
+ declare function useFollow(featureId: string): UseFollowResult;
490
+
491
+ interface UseFewchursConfigResult {
492
+ showBranding: boolean;
493
+ hideVoteCounts: boolean;
494
+ appName: string | null;
495
+ isLoading: boolean;
496
+ }
497
+ declare function useFewchursConfig(): UseFewchursConfigResult;
498
+
499
+ declare const COOKIE_NAME = "fewchurs_did";
500
+ declare function createDeviceId(): string;
501
+ declare function readDeviceId(): string | null;
502
+ /**
503
+ * Cookie first, storage second: a cookie is the only copy a same-origin server can read, so
504
+ * the board renders with the same identity on both sides and never mismatches on hydration.
505
+ */
506
+ declare function resolveDeviceId(): string;
507
+
508
+ declare function normalizeStatus(value: unknown): FeatureStatus;
509
+ declare function normalizeFeature(value: unknown, deviceId: string | null): Feature;
510
+ declare function normalizeComment(value: unknown, deviceId: string | null): Comment;
511
+ declare function normalizeFeaturePage(value: unknown, deviceId: string | null): FeaturePage;
512
+ declare function normalizeCommentPage(value: unknown, deviceId: string | null): CommentPage;
513
+ declare function normalizeConfig(value: unknown): FewchursConfig;
514
+ declare function normalizeVote(value: unknown): {
515
+ upvoteCount: number;
516
+ hasVoted: boolean;
517
+ };
518
+
519
+ declare function validateFeatureInput(input: SubmitFeatureInput): FewchursError | null;
520
+ declare function validateCommentInput(input: CreateCommentInput): FewchursError | null;
521
+ declare function validateEmail(email: string): FewchursError | null;
522
+
523
+ export { type BoardContextValue, type BoardLabels, BoardRoot, type BoardTab, BoardTabs, type BoardTheme, Branding, type Comment, CommentForm, CommentList, type CommentPage, type CommentsState, type CreateCommentInput, DEFAULT_BASE_URL, DEFAULT_LABELS, DEFAULT_PAGE_SIZE, COOKIE_NAME as DEVICE_ID_COOKIE, type EmailFieldMode, type EventHandler, type Feature, type FeatureActions, FeatureCard, FeatureList, type FeaturePage, type FeatureSort, type FeatureStatus, type FetchLike, FewchursBoard, FewchursBoardDialog, type FewchursBoardDialogProps, type FewchursBoardProps, type FewchursClient, type FewchursClientOptions, type FewchursConfig, type FewchursContextValue, type FewchursError, type FewchursEvent, type FewchursEventName, FewchursProvider, type FewchursProviderProps, FollowForm, LIMITS, type ListCommentsParams, type ListFeaturesParams, type ListQuery, type ListState, type LoadStatus, ROADMAP_STATUSES, type Result, SDK_HEADER, SDK_VERSION, StatusBadge, type Store, type StoreOptions, type StoreState, type SubmitFeatureInput, SubmitForm, SubmitTrigger, type UseCommentsOptions, type UseCommentsResult, type UseFeatureOptions, type UseFeatureResult, type UseFeaturesOptions, type UseFeaturesResult, type UseFewchursConfigResult, type UseFollowResult, type UseSubmitFeatureResult, type UseVoteResult, VoteButton, commentsStateOf, createDeviceId, createFewchursClient, createStore, errorFromResponse, featureKey, listKey, listStateOf, mergeLabels, networkError, normalizeComment, normalizeCommentPage, normalizeConfig, normalizeFeature, normalizeFeaturePage, normalizeStatus, normalizeVote, readDeviceId, resolveDeviceId, useBoard, useComments, useFeature, useFeatures, useFewchurs, useFewchursConfig, useFollow, useSubmitFeature, useVote, userMessage, validateCommentInput, validateEmail, validateFeatureInput, validationError };