@shipnoted/react 0.0.0-stage → 0.1.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/README.md +30 -2
- package/dist/core/changelog.d.ts +25 -0
- package/dist/core/changelog.js +56 -0
- package/dist/core/client.d.ts +122 -0
- package/dist/core/client.js +76 -0
- package/dist/core/feedback.d.ts +34 -0
- package/dist/core/feedback.js +52 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +4 -0
- package/dist/core/store.d.ts +7 -0
- package/dist/core/store.js +17 -0
- package/dist/hooks.d.ts +62 -0
- package/dist/hooks.js +90 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +24 -0
- package/package.json +20 -4
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shipnoted/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Headless React hooks for [Shipnoted](https://shipnoted.com): a changelog, a feedback board, a subscribe form and release reactions in your own components. You write the markup; this holds the data and the logic.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i @shipnoted/react
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { ShipnotedProvider, useChangelog, useFeedback } from "@shipnoted/react"
|
|
11
|
+
|
|
12
|
+
function Page() {
|
|
13
|
+
const { releases, hasMore, loadMore, unread, markSeen } = useChangelog({ limit: 10 })
|
|
14
|
+
const { items, vote, hasVoted, submit } = useFeedback({ status: "open", sort: "top" })
|
|
15
|
+
// render however you like
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
<ShipnotedProvider project="your-slug"><Page /></ShipnotedProvider>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **No API key.** It uses the open API the widget uses: public reads, and visitors voting, posting and subscribing, all rate limited.
|
|
22
|
+
- **Optimistic votes** that are put back if the server refuses them.
|
|
23
|
+
- **Server rendering:** fetch the first page on the server and pass it as `initialData`: no loading state on first paint.
|
|
24
|
+
- **Signed-in customers:** pass `userToken` (a short JWT your backend signs) and votes count as that verified customer, with plan and revenue.
|
|
25
|
+
- **Framework-free core:** `@shipnoted/react/core` exports the same logic as plain stores (`getSnapshot`, `subscribe`) for Vue, Svelte and scripts.
|
|
26
|
+
|
|
27
|
+
Hooks: `useChangelog`, `useFeedback`, `useSubscribe`, `useReactions`, `useProjectConfig`. Full guide: https://shipnoted.com/docs/react
|
|
28
|
+
|
|
29
|
+
Prefer ready-made UI? The shadcn components (`npx shadcn@latest add https://shipnoted.com/r/whats-new.json`) are built on the same API.
|
|
30
|
+
|
|
31
|
+
MIT licensed.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PublicClient, Release } from "./client.js";
|
|
2
|
+
export type ChangelogState = {
|
|
3
|
+
releases: Release[];
|
|
4
|
+
loading: boolean;
|
|
5
|
+
loadingMore: boolean;
|
|
6
|
+
error: string | null;
|
|
7
|
+
hasMore: boolean;
|
|
8
|
+
seen: number;
|
|
9
|
+
};
|
|
10
|
+
export type ChangelogOptions = {
|
|
11
|
+
limit?: number;
|
|
12
|
+
lang?: string;
|
|
13
|
+
initialData?: Release[];
|
|
14
|
+
};
|
|
15
|
+
/** The changelog as plain state and actions. Pages of `limit` releases; "unread" is releases newer than the last one this browser marked as seen. */
|
|
16
|
+
export declare function createChangelog(client: PublicClient, opts?: ChangelogOptions): {
|
|
17
|
+
refresh: () => Promise<void>;
|
|
18
|
+
loadMore: () => Promise<void>;
|
|
19
|
+
markSeen: () => void;
|
|
20
|
+
unread: (s?: ChangelogState) => number;
|
|
21
|
+
getSnapshot: () => ChangelogState;
|
|
22
|
+
subscribe: (fn: () => void) => () => void;
|
|
23
|
+
set: (patch: Partial<ChangelogState> | ((s: ChangelogState) => Partial<ChangelogState>)) => void;
|
|
24
|
+
};
|
|
25
|
+
export type Changelog = ReturnType<typeof createChangelog>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createStore } from "./store.js";
|
|
2
|
+
/** The changelog as plain state and actions. Pages of `limit` releases; "unread" is releases newer than the last one this browser marked as seen. */
|
|
3
|
+
export function createChangelog(client, opts = {}) {
|
|
4
|
+
const limit = Math.min(30, Math.max(1, opts.limit ?? 10));
|
|
5
|
+
const seenKey = `shipnoted:${client.project}:seen`;
|
|
6
|
+
const readSeen = () => {
|
|
7
|
+
try {
|
|
8
|
+
return Number(client.storage?.getItem(seenKey) ?? 0) || 0;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const store = createStore({ releases: opts.initialData ?? [], loading: !opts.initialData, loadingMore: false, error: null, hasMore: (opts.initialData?.length ?? 0) >= limit, seen: 0 });
|
|
15
|
+
let run = 0;
|
|
16
|
+
async function refresh() {
|
|
17
|
+
const my = ++run;
|
|
18
|
+
store.set({ loading: true, error: null, seen: readSeen() });
|
|
19
|
+
try {
|
|
20
|
+
const d = await client.releases({ limit, lang: opts.lang });
|
|
21
|
+
if (my === run)
|
|
22
|
+
store.set({ releases: d.releases, hasMore: d.releases.length >= limit, loading: false });
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (my === run)
|
|
26
|
+
store.set({ loading: false, error: e instanceof Error ? e.message : "Could not load the changelog." });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function loadMore() {
|
|
30
|
+
const s = store.getSnapshot();
|
|
31
|
+
const last = s.releases[s.releases.length - 1];
|
|
32
|
+
if (!last || !s.hasMore || s.loadingMore)
|
|
33
|
+
return;
|
|
34
|
+
store.set({ loadingMore: true });
|
|
35
|
+
try {
|
|
36
|
+
const d = await client.releases({ limit, lang: opts.lang, before: last.publishedAt });
|
|
37
|
+
const have = new Set(store.getSnapshot().releases.map((r) => r.id));
|
|
38
|
+
store.set((c) => ({ releases: [...c.releases, ...d.releases.filter((r) => !have.has(r.id))], hasMore: d.releases.length >= limit, loadingMore: false }));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
store.set({ loadingMore: false, error: e instanceof Error ? e.message : "Could not load more." });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function markSeen() {
|
|
45
|
+
const newest = store.getSnapshot().releases[0]?.publishedAt;
|
|
46
|
+
if (!newest)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
client.storage?.setItem(seenKey, String(newest));
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
store.set({ seen: newest });
|
|
53
|
+
}
|
|
54
|
+
const unread = (s = store.getSnapshot()) => s.releases.filter((r) => r.publishedAt > s.seen).length;
|
|
55
|
+
return { ...store, refresh, loadMore, markSeen, unread };
|
|
56
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public API of one project: the same endpoints the widget uses. Nothing here needs an API key. With a `userToken` (a short JWT your own backend signs
|
|
3
|
+
* for the signed-in person) votes and ideas count as that verified customer; a token that has gone bad is dropped and the call is retried anonymously.
|
|
4
|
+
*/
|
|
5
|
+
export type Release = {
|
|
6
|
+
id: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
title: string;
|
|
9
|
+
version: string;
|
|
10
|
+
tags: string[];
|
|
11
|
+
publishedAt: number;
|
|
12
|
+
summary: string;
|
|
13
|
+
body: string;
|
|
14
|
+
url: string;
|
|
15
|
+
lang: string;
|
|
16
|
+
asked: string[];
|
|
17
|
+
announce: boolean;
|
|
18
|
+
targeted: boolean;
|
|
19
|
+
};
|
|
20
|
+
export type RequestStatus = "open" | "planned" | "progress" | "shipped" | "declined";
|
|
21
|
+
export type FeedbackItem = {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
body: string;
|
|
25
|
+
status: RequestStatus | string;
|
|
26
|
+
category: string;
|
|
27
|
+
votes: number;
|
|
28
|
+
comments: number;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
author: string;
|
|
31
|
+
verified?: boolean;
|
|
32
|
+
};
|
|
33
|
+
export type SimilarItem = {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
votes: number;
|
|
37
|
+
status: string;
|
|
38
|
+
};
|
|
39
|
+
export type ProjectConfig = {
|
|
40
|
+
name: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
accent: string;
|
|
43
|
+
onAccent: string;
|
|
44
|
+
dark: boolean;
|
|
45
|
+
vars: Record<string, string>;
|
|
46
|
+
feedback: boolean;
|
|
47
|
+
roadmap: boolean;
|
|
48
|
+
badge: boolean;
|
|
49
|
+
};
|
|
50
|
+
export type ClientOptions = {
|
|
51
|
+
project: string;
|
|
52
|
+
/** Where Shipnoted lives. Default https://shipnoted.com, or your custom domain's origin. */
|
|
53
|
+
baseUrl?: string;
|
|
54
|
+
userToken?: string | (() => string | undefined);
|
|
55
|
+
fetch?: typeof fetch;
|
|
56
|
+
/** Where the anonymous voter id and "seen" marks are kept. Default: localStorage when there is one. */
|
|
57
|
+
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
58
|
+
};
|
|
59
|
+
export declare class ShipnotedApiError extends Error {
|
|
60
|
+
status: number;
|
|
61
|
+
code?: string;
|
|
62
|
+
constructor(message: string, status: number, code?: string);
|
|
63
|
+
}
|
|
64
|
+
export declare function createPublicClient(opts: ClientOptions): {
|
|
65
|
+
project: string;
|
|
66
|
+
voterId: () => string;
|
|
67
|
+
storage: Pick<Storage, "getItem" | "setItem"> | null;
|
|
68
|
+
config: () => Promise<{
|
|
69
|
+
ok: true;
|
|
70
|
+
} & ProjectConfig>;
|
|
71
|
+
releases: (o?: {
|
|
72
|
+
limit?: number;
|
|
73
|
+
before?: number;
|
|
74
|
+
lang?: string;
|
|
75
|
+
}) => Promise<{
|
|
76
|
+
ok: true;
|
|
77
|
+
releases: Release[];
|
|
78
|
+
}>;
|
|
79
|
+
feedback: () => Promise<{
|
|
80
|
+
ok: true;
|
|
81
|
+
items: FeedbackItem[];
|
|
82
|
+
}>;
|
|
83
|
+
voted: () => Promise<{
|
|
84
|
+
ok: true;
|
|
85
|
+
ids: string[];
|
|
86
|
+
identified: boolean;
|
|
87
|
+
}>;
|
|
88
|
+
similar: (text: string) => Promise<{
|
|
89
|
+
ok: true;
|
|
90
|
+
items: SimilarItem[];
|
|
91
|
+
}>;
|
|
92
|
+
vote: (id: string) => Promise<{
|
|
93
|
+
ok: true;
|
|
94
|
+
voted: boolean;
|
|
95
|
+
votes: number;
|
|
96
|
+
}>;
|
|
97
|
+
submit: (input: {
|
|
98
|
+
title: string;
|
|
99
|
+
body?: string;
|
|
100
|
+
email?: string;
|
|
101
|
+
name?: string;
|
|
102
|
+
category?: string;
|
|
103
|
+
}) => Promise<{
|
|
104
|
+
ok: true;
|
|
105
|
+
feedback: FeedbackItem;
|
|
106
|
+
}>;
|
|
107
|
+
subscribe: (email: string, categories?: string[]) => Promise<{
|
|
108
|
+
ok: true;
|
|
109
|
+
already?: boolean;
|
|
110
|
+
}>;
|
|
111
|
+
reactions: (ids: string[]) => Promise<{
|
|
112
|
+
ok: true;
|
|
113
|
+
counts: Record<string, Record<string, number>>;
|
|
114
|
+
mine: Record<string, string[]>;
|
|
115
|
+
}>;
|
|
116
|
+
react: (release: string, emoji: string) => Promise<{
|
|
117
|
+
ok: true;
|
|
118
|
+
on: boolean;
|
|
119
|
+
counts: Record<string, number>;
|
|
120
|
+
}>;
|
|
121
|
+
};
|
|
122
|
+
export type PublicClient = ReturnType<typeof createPublicClient>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export class ShipnotedApiError extends Error {
|
|
2
|
+
constructor(message, status, code) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "ShipnotedApiError";
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const VOTER = /^[A-Za-z0-9_-]{12,64}$/;
|
|
10
|
+
const fresh = () => {
|
|
11
|
+
const c = globalThis.crypto;
|
|
12
|
+
return (c?.randomUUID ? c.randomUUID() + c.randomUUID() : Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) + Date.now().toString(36)).replace(/-/g, "").slice(0, 32);
|
|
13
|
+
};
|
|
14
|
+
export function createPublicClient(opts) {
|
|
15
|
+
const base = `${(opts.baseUrl ?? "https://shipnoted.com").replace(/\/$/, "")}/api/public/${encodeURIComponent(opts.project)}`;
|
|
16
|
+
const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
17
|
+
const storage = opts.storage === undefined ? (typeof localStorage === "undefined" ? null : localStorage) : opts.storage;
|
|
18
|
+
let memoryVoter = "";
|
|
19
|
+
let tokenDropped = false;
|
|
20
|
+
const token = () => {
|
|
21
|
+
if (tokenDropped)
|
|
22
|
+
return "";
|
|
23
|
+
return (typeof opts.userToken === "function" ? opts.userToken() : opts.userToken) ?? "";
|
|
24
|
+
};
|
|
25
|
+
/** This browser's anonymous id: made once and kept, so a visitor is one voter across visits. */
|
|
26
|
+
function voterId() {
|
|
27
|
+
if (!storage)
|
|
28
|
+
return (memoryVoter || (memoryVoter = fresh()));
|
|
29
|
+
try {
|
|
30
|
+
let v = storage.getItem("sn_voter") ?? "";
|
|
31
|
+
if (!VOTER.test(v)) {
|
|
32
|
+
v = fresh();
|
|
33
|
+
storage.setItem("sn_voter", v);
|
|
34
|
+
}
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return (memoryVoter || (memoryVoter = fresh()));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function request(method, path, body, withToken = true) {
|
|
42
|
+
const t = withToken ? token() : "";
|
|
43
|
+
const url = `${base}${path}${method === "GET" && t ? `${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(t)}` : ""}`;
|
|
44
|
+
const res = await f(url, { method, headers: body ? { "content-type": "application/json" } : undefined, body: body ? JSON.stringify(t ? { ...body, token: t } : body) : undefined });
|
|
45
|
+
const data = (await res.json().catch(() => ({})));
|
|
46
|
+
if (res.status === 401 && data.code === "bad_token" && t) {
|
|
47
|
+
tokenDropped = true;
|
|
48
|
+
return request(method, path, body, false);
|
|
49
|
+
}
|
|
50
|
+
if (!res.ok || data.ok === false)
|
|
51
|
+
throw new ShipnotedApiError(data.error ?? "Something went wrong.", res.status, data.code);
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
const q = (o) => {
|
|
55
|
+
const p = new URLSearchParams();
|
|
56
|
+
for (const [k, v] of Object.entries(o))
|
|
57
|
+
if (v !== undefined && v !== "")
|
|
58
|
+
p.set(k, String(v));
|
|
59
|
+
return p.size ? `?${p}` : "";
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
project: opts.project,
|
|
63
|
+
voterId,
|
|
64
|
+
storage,
|
|
65
|
+
config: () => request("GET", "/config"),
|
|
66
|
+
releases: (o = {}) => request("GET", `/releases${q(o)}`),
|
|
67
|
+
feedback: () => request("GET", "/feedback-list"),
|
|
68
|
+
voted: () => request("GET", `/votes${q({ voter: voterId() })}`),
|
|
69
|
+
similar: (text) => request("GET", `/similar${q({ q: text.slice(0, 200) })}`),
|
|
70
|
+
vote: (id) => request("POST", "/vote", { id, voter: voterId() }),
|
|
71
|
+
submit: (input) => request("POST", "/feedback", { ...input, voter: voterId() }),
|
|
72
|
+
subscribe: (email, categories = []) => request("POST", "/subscribe", { email, categories }),
|
|
73
|
+
reactions: (ids) => request("GET", `/react${q({ ids: ids.join(","), voter: voterId() })}`),
|
|
74
|
+
react: (release, emoji) => request("POST", "/react", { release, emoji, voter: voterId() }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FeedbackItem, PublicClient, SimilarItem } from "./client.js";
|
|
2
|
+
export type FeedbackState = {
|
|
3
|
+
items: FeedbackItem[];
|
|
4
|
+
voted: string[];
|
|
5
|
+
loading: boolean;
|
|
6
|
+
submitting: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
};
|
|
9
|
+
export type FeedbackFilter = {
|
|
10
|
+
sort?: "top" | "new";
|
|
11
|
+
status?: string;
|
|
12
|
+
q?: string;
|
|
13
|
+
};
|
|
14
|
+
/** The board as plain state and actions. Votes show at once and are put back if the server says no. */
|
|
15
|
+
export declare function createFeedback(client: PublicClient, opts?: {
|
|
16
|
+
initialData?: FeedbackItem[];
|
|
17
|
+
}): {
|
|
18
|
+
refresh: () => Promise<void>;
|
|
19
|
+
vote: (id: string) => Promise<boolean>;
|
|
20
|
+
submit: (input: {
|
|
21
|
+
title: string;
|
|
22
|
+
body?: string;
|
|
23
|
+
email?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
category?: string;
|
|
26
|
+
}) => Promise<FeedbackItem | null>;
|
|
27
|
+
similar: (text: string) => Promise<SimilarItem[]>;
|
|
28
|
+
getSnapshot: () => FeedbackState;
|
|
29
|
+
subscribe: (fn: () => void) => () => void;
|
|
30
|
+
set: (patch: Partial<FeedbackState> | ((s: FeedbackState) => Partial<FeedbackState>)) => void;
|
|
31
|
+
};
|
|
32
|
+
export type Feedback = ReturnType<typeof createFeedback>;
|
|
33
|
+
/** Filter and order the loaded items. Search matches words in the title and body, case-insensitively. */
|
|
34
|
+
export declare function visibleItems(items: FeedbackItem[], f?: FeedbackFilter): FeedbackItem[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createStore } from "./store.js";
|
|
2
|
+
/** The board as plain state and actions. Votes show at once and are put back if the server says no. */
|
|
3
|
+
export function createFeedback(client, opts = {}) {
|
|
4
|
+
const store = createStore({ items: opts.initialData ?? [], voted: [], loading: !opts.initialData, submitting: false, error: null });
|
|
5
|
+
async function refresh() {
|
|
6
|
+
store.set({ loading: true, error: null });
|
|
7
|
+
try {
|
|
8
|
+
const [list, voted] = await Promise.all([client.feedback(), client.voted().catch(() => ({ ids: [] }))]);
|
|
9
|
+
store.set({ items: list.items, voted: voted.ids, loading: false });
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
store.set({ loading: false, error: e instanceof Error ? e.message : "Could not load the board." });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Toggle your vote. Resolves to whether it is now on. */
|
|
16
|
+
async function vote(id) {
|
|
17
|
+
const was = store.getSnapshot().voted.includes(id);
|
|
18
|
+
const apply = (on, delta) => store.set((s) => ({ voted: on ? [...new Set([...s.voted, id])] : s.voted.filter((x) => x !== id), items: s.items.map((i) => (i.id === id ? { ...i, votes: Math.max(0, i.votes + delta) } : i)) }));
|
|
19
|
+
apply(!was, was ? -1 : 1);
|
|
20
|
+
try {
|
|
21
|
+
const r = await client.vote(id);
|
|
22
|
+
store.set((s) => ({ voted: r.voted ? [...new Set([...s.voted, id])] : s.voted.filter((x) => x !== id), items: s.items.map((i) => (i.id === id ? { ...i, votes: r.votes } : i)) }));
|
|
23
|
+
return r.voted;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
apply(was, was ? 1 : -1);
|
|
27
|
+
store.set({ error: e instanceof Error ? e.message : "Could not record that vote." });
|
|
28
|
+
return was;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function submit(input) {
|
|
32
|
+
store.set({ submitting: true, error: null });
|
|
33
|
+
try {
|
|
34
|
+
const r = await client.submit(input);
|
|
35
|
+
store.set((s) => ({ items: [r.feedback, ...s.items], voted: [...new Set([...s.voted, r.feedback.id])], submitting: false }));
|
|
36
|
+
return r.feedback;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
store.set({ submitting: false, error: e instanceof Error ? e.message : "Could not post that." });
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Requests that already say this, to show while someone types an idea. */
|
|
44
|
+
const similar = async (text) => (text.trim().length < 6 ? [] : (await client.similar(text).catch(() => ({ items: [] }))).items);
|
|
45
|
+
return { ...store, refresh, vote, submit, similar };
|
|
46
|
+
}
|
|
47
|
+
/** Filter and order the loaded items. Search matches words in the title and body, case-insensitively. */
|
|
48
|
+
export function visibleItems(items, f = {}) {
|
|
49
|
+
const words = (f.q ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
50
|
+
const list = items.filter((i) => (!f.status || f.status === "all" || i.status === f.status) && words.every((w) => `${i.title} ${i.body}`.toLowerCase().includes(w)));
|
|
51
|
+
return list.sort(f.sort === "new" ? (a, b) => b.createdAt - a.createdAt : (a, b) => b.votes - a.votes || b.createdAt - a.createdAt);
|
|
52
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A tiny observable value: the shape `useSyncExternalStore` wants, and just as usable from Vue, Svelte or plain scripts. */
|
|
2
|
+
export type Store<S> = {
|
|
3
|
+
getSnapshot: () => S;
|
|
4
|
+
subscribe: (fn: () => void) => () => void;
|
|
5
|
+
set: (patch: Partial<S> | ((s: S) => Partial<S>)) => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function createStore<S extends object>(initial: S): Store<S>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function createStore(initial) {
|
|
2
|
+
let state = initial;
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
return {
|
|
5
|
+
getSnapshot: () => state,
|
|
6
|
+
subscribe(fn) {
|
|
7
|
+
listeners.add(fn);
|
|
8
|
+
return () => listeners.delete(fn);
|
|
9
|
+
},
|
|
10
|
+
set(patch) {
|
|
11
|
+
const next = typeof patch === "function" ? patch(state) : patch;
|
|
12
|
+
state = { ...state, ...next };
|
|
13
|
+
for (const l of [...listeners])
|
|
14
|
+
l();
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ChangelogOptions, type ClientOptions, type FeedbackFilter, type FeedbackItem, type ProjectConfig, type Release } from "./core/index.js";
|
|
2
|
+
type Common = Partial<Pick<ClientOptions, "project" | "baseUrl" | "userToken">>;
|
|
3
|
+
/**
|
|
4
|
+
* The changelog, headless. You render it; this holds the data.
|
|
5
|
+
*
|
|
6
|
+
* const { releases, loading, hasMore, loadMore, unread, markSeen } = useChangelog({ limit: 10 })
|
|
7
|
+
*
|
|
8
|
+
* `initialData` lets a server component fetch the first page, so there is no loading state on first paint.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useChangelog(opts?: ChangelogOptions & Common): {
|
|
11
|
+
unread: number;
|
|
12
|
+
loadMore: () => Promise<void>;
|
|
13
|
+
refresh: () => Promise<void>;
|
|
14
|
+
markSeen: () => void;
|
|
15
|
+
releases: Release[];
|
|
16
|
+
loading: boolean;
|
|
17
|
+
loadingMore: boolean;
|
|
18
|
+
error: string | null;
|
|
19
|
+
hasMore: boolean;
|
|
20
|
+
seen: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The feedback board, headless: the list, which ones you voted for, voting and posting. Votes are optimistic.
|
|
24
|
+
*
|
|
25
|
+
* const { items, voted, vote, submit, similar } = useFeedback({ sort: "top", status: "open" })
|
|
26
|
+
*/
|
|
27
|
+
export declare function useFeedback(opts?: FeedbackFilter & Common & {
|
|
28
|
+
initialData?: FeedbackItem[];
|
|
29
|
+
}): {
|
|
30
|
+
items: FeedbackItem[];
|
|
31
|
+
vote: (id: string) => Promise<boolean>;
|
|
32
|
+
submit: (input: {
|
|
33
|
+
title: string;
|
|
34
|
+
body?: string;
|
|
35
|
+
email?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
category?: string;
|
|
38
|
+
}) => Promise<FeedbackItem | null>;
|
|
39
|
+
similar: (text: string) => Promise<import("./index.js").SimilarItem[]>;
|
|
40
|
+
refresh: () => Promise<void>;
|
|
41
|
+
hasVoted: (id: string) => boolean;
|
|
42
|
+
voted: string[];
|
|
43
|
+
loading: boolean;
|
|
44
|
+
submitting: boolean;
|
|
45
|
+
error: string | null;
|
|
46
|
+
};
|
|
47
|
+
/** An email box: `subscribe(email, ["fixed"])` starts the double opt-in; nobody is emailed until they confirm. */
|
|
48
|
+
export declare function useSubscribe(opts?: Common): {
|
|
49
|
+
subscribe: (email: string, categories?: string[]) => Promise<boolean>;
|
|
50
|
+
status: "error" | "idle" | "busy" | "sent" | "already";
|
|
51
|
+
error: string | null;
|
|
52
|
+
busy: boolean;
|
|
53
|
+
};
|
|
54
|
+
/** Reactions under a release: counts, which ones are yours, and toggling. */
|
|
55
|
+
export declare function useReactions(release: Pick<Release, "id">, opts?: Common): {
|
|
56
|
+
counts: Record<string, number>;
|
|
57
|
+
mine: string[];
|
|
58
|
+
toggle: (emoji: string) => Promise<void>;
|
|
59
|
+
};
|
|
60
|
+
/** The project's look (accent, theme variables) and switches, to style your own UI to match. */
|
|
61
|
+
export declare function useProjectConfig(opts?: Common): ProjectConfig | null;
|
|
62
|
+
export {};
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { createChangelog, createFeedback, visibleItems } from "./core/index.js";
|
|
4
|
+
import { useClient } from "./provider.js";
|
|
5
|
+
/**
|
|
6
|
+
* The changelog, headless. You render it; this holds the data.
|
|
7
|
+
*
|
|
8
|
+
* const { releases, loading, hasMore, loadMore, unread, markSeen } = useChangelog({ limit: 10 })
|
|
9
|
+
*
|
|
10
|
+
* `initialData` lets a server component fetch the first page, so there is no loading state on first paint.
|
|
11
|
+
*/
|
|
12
|
+
export function useChangelog(opts = {}) {
|
|
13
|
+
const client = useClient(opts);
|
|
14
|
+
const { limit, lang, initialData } = opts;
|
|
15
|
+
const log = useMemo(() => createChangelog(client, { limit, lang, initialData }), [client, limit, lang, initialData]);
|
|
16
|
+
const state = useSyncExternalStore(log.subscribe, log.getSnapshot, log.getSnapshot);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!initialData)
|
|
19
|
+
void log.refresh();
|
|
20
|
+
else
|
|
21
|
+
log.set({ seen: Number(client.storage?.getItem(`shipnoted:${client.project}:seen`) ?? 0) || 0 });
|
|
22
|
+
}, [log, initialData, client]);
|
|
23
|
+
return { ...state, unread: log.unread(state), loadMore: log.loadMore, refresh: log.refresh, markSeen: log.markSeen };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The feedback board, headless: the list, which ones you voted for, voting and posting. Votes are optimistic.
|
|
27
|
+
*
|
|
28
|
+
* const { items, voted, vote, submit, similar } = useFeedback({ sort: "top", status: "open" })
|
|
29
|
+
*/
|
|
30
|
+
export function useFeedback(opts = {}) {
|
|
31
|
+
const client = useClient(opts);
|
|
32
|
+
const { initialData, sort, status, q } = opts;
|
|
33
|
+
const board = useMemo(() => createFeedback(client, { initialData }), [client, initialData]);
|
|
34
|
+
const state = useSyncExternalStore(board.subscribe, board.getSnapshot, board.getSnapshot);
|
|
35
|
+
useEffect(() => { void board.refresh(); }, [board]);
|
|
36
|
+
const items = useMemo(() => visibleItems(state.items, { sort, status, q }), [state.items, sort, status, q]);
|
|
37
|
+
return { ...state, items, vote: board.vote, submit: board.submit, similar: board.similar, refresh: board.refresh, hasVoted: (id) => state.voted.includes(id) };
|
|
38
|
+
}
|
|
39
|
+
/** An email box: `subscribe(email, ["fixed"])` starts the double opt-in; nobody is emailed until they confirm. */
|
|
40
|
+
export function useSubscribe(opts = {}) {
|
|
41
|
+
const client = useClient(opts);
|
|
42
|
+
const [status, setStatus] = useState("idle");
|
|
43
|
+
const [error, setError] = useState(null);
|
|
44
|
+
const subscribe = useCallback(async (email, categories = []) => {
|
|
45
|
+
setStatus("busy");
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
const r = await client.subscribe(email, categories);
|
|
49
|
+
setStatus(r.already ? "already" : "sent");
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
setStatus("error");
|
|
54
|
+
setError(e instanceof Error ? e.message : "Could not subscribe.");
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}, [client]);
|
|
58
|
+
return { subscribe, status, error, busy: status === "busy" };
|
|
59
|
+
}
|
|
60
|
+
/** Reactions under a release: counts, which ones are yours, and toggling. */
|
|
61
|
+
export function useReactions(release, opts = {}) {
|
|
62
|
+
const client = useClient(opts);
|
|
63
|
+
const [counts, setCounts] = useState({});
|
|
64
|
+
const [mine, setMine] = useState([]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
let live = true;
|
|
67
|
+
client.reactions([release.id]).then((d) => { if (live) {
|
|
68
|
+
setCounts(d.counts[release.id] ?? {});
|
|
69
|
+
setMine(d.mine[release.id] ?? []);
|
|
70
|
+
} }).catch(() => { });
|
|
71
|
+
return () => { live = false; };
|
|
72
|
+
}, [client, release.id]);
|
|
73
|
+
const toggle = useCallback(async (emoji) => {
|
|
74
|
+
const r = await client.react(release.id, emoji);
|
|
75
|
+
setCounts(r.counts);
|
|
76
|
+
setMine((m) => (r.on ? [...new Set([...m, emoji])] : m.filter((x) => x !== emoji)));
|
|
77
|
+
}, [client, release.id]);
|
|
78
|
+
return { counts, mine, toggle };
|
|
79
|
+
}
|
|
80
|
+
/** The project's look (accent, theme variables) and switches, to style your own UI to match. */
|
|
81
|
+
export function useProjectConfig(opts = {}) {
|
|
82
|
+
const client = useClient(opts);
|
|
83
|
+
const [config, setConfig] = useState(null);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let live = true;
|
|
86
|
+
client.config().then((c) => live && setConfig(c)).catch(() => { });
|
|
87
|
+
return () => { live = false; };
|
|
88
|
+
}, [client]);
|
|
89
|
+
return config;
|
|
90
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ShipnotedProvider, useClient } from "./provider.js";
|
|
2
|
+
export { useChangelog, useFeedback, useSubscribe, useReactions, useProjectConfig } from "./hooks.js";
|
|
3
|
+
export { createPublicClient, createChangelog, createFeedback, createStore, visibleItems, ShipnotedApiError } from "./core/index.js";
|
|
4
|
+
export type { Release, FeedbackItem, SimilarItem, RequestStatus, ProjectConfig, ClientOptions, PublicClient, ChangelogState, ChangelogOptions, FeedbackState, FeedbackFilter } from "./core/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ShipnotedProvider, useClient } from "./provider.js";
|
|
2
|
+
export { useChangelog, useFeedback, useSubscribe, useReactions, useProjectConfig } from "./hooks.js";
|
|
3
|
+
export { createPublicClient, createChangelog, createFeedback, createStore, visibleItems, ShipnotedApiError } from "./core/index.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type ClientOptions, type PublicClient } from "./core/index.js";
|
|
3
|
+
/** Tell every hook below which project (and, optionally, which signed-in customer) it is for. Hooks also work without it if you pass `project` to each. */
|
|
4
|
+
export declare function ShipnotedProvider({ children, ...options }: ClientOptions & {
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
export declare function useClient(options?: Partial<ClientOptions>): PublicClient;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useContext, useMemo } from "react";
|
|
4
|
+
import { createPublicClient } from "./core/index.js";
|
|
5
|
+
const Ctx = createContext(null);
|
|
6
|
+
/** Tell every hook below which project (and, optionally, which signed-in customer) it is for. Hooks also work without it if you pass `project` to each. */
|
|
7
|
+
export function ShipnotedProvider({ children, ...options }) {
|
|
8
|
+
const { project, baseUrl, fetch: f, storage } = options;
|
|
9
|
+
const userToken = options.userToken;
|
|
10
|
+
// The client is rebuilt only when the project, address or token function changes, not on every render.
|
|
11
|
+
const client = useMemo(() => createPublicClient({ project, baseUrl, userToken, fetch: f, storage }), [project, baseUrl, userToken, f, storage]);
|
|
12
|
+
return _jsx(Ctx.Provider, { value: client, children: children });
|
|
13
|
+
}
|
|
14
|
+
export function useClient(options) {
|
|
15
|
+
const ctx = useContext(Ctx);
|
|
16
|
+
const own = options?.project;
|
|
17
|
+
const baseUrl = options?.baseUrl;
|
|
18
|
+
const userToken = options?.userToken;
|
|
19
|
+
const client = useMemo(() => (own ? createPublicClient({ ...options, project: own, baseUrl, userToken }) : null), [own, baseUrl, userToken]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
20
|
+
const found = client ?? ctx;
|
|
21
|
+
if (!found)
|
|
22
|
+
throw new Error("Wrap your app in <ShipnotedProvider project=\"your-slug\"> or pass { project } to the hook.");
|
|
23
|
+
return found;
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipnoted/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless React hooks for Shipnoted: a changelog, a feedback board and a subscribe form in your own components. Framework-free core included.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
|
12
|
+
"./core": { "types": "./dist/core/index.d.ts", "default": "./dist/core/index.js" },
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "README.md"],
|
|
16
|
+
"scripts": { "build": "rm -rf dist && tsc -p tsconfig.json" },
|
|
17
|
+
"peerDependencies": { "react": ">=18" },
|
|
18
|
+
"devDependencies": { "typescript": "^5", "@types/react": "^19" },
|
|
19
|
+
"keywords": ["changelog", "feedback", "roadmap", "react", "hooks", "headless", "shipnoted"],
|
|
20
|
+
"repository": { "type": "git", "url": "git+https://github.com/vamsiy78/shipnoted.git", "directory": "react" },
|
|
21
|
+
"homepage": "https://shipnoted.com/docs/react"
|
|
22
|
+
}
|