@indietabletop/appkit 3.6.0-1 → 3.6.0-3

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.
Files changed (44) hide show
  1. package/lib/AppConfig/AppConfig.tsx +54 -0
  2. package/lib/HistoryState.ts +21 -0
  3. package/lib/Letterhead/style.css.ts +2 -0
  4. package/lib/LetterheadForm/index.tsx +53 -0
  5. package/lib/LetterheadForm/style.css.ts +8 -0
  6. package/lib/QRCode/QRCode.stories.tsx +47 -0
  7. package/lib/QRCode/QRCode.tsx +49 -0
  8. package/lib/QRCode/style.css.ts +19 -0
  9. package/lib/ShareButton/ShareButton.tsx +141 -0
  10. package/lib/SubscribeCard/LetterheadInfoCard.tsx +23 -0
  11. package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +83 -0
  12. package/lib/SubscribeCard/SubscribeByEmailCard.tsx +177 -0
  13. package/lib/SubscribeCard/SubscribeCard.stories.tsx +27 -23
  14. package/lib/SubscribeCard/SubscribeCard.tsx +22 -17
  15. package/lib/Title/index.tsx +7 -2
  16. package/lib/account/AccountIssueView.tsx +40 -0
  17. package/lib/account/AlreadyLoggedInView.tsx +44 -0
  18. package/lib/account/CurrentUserFetcher.stories.tsx +339 -0
  19. package/lib/account/CurrentUserFetcher.tsx +119 -0
  20. package/lib/account/FailureFallbackView.tsx +36 -0
  21. package/lib/account/JoinPage.stories.tsx +270 -0
  22. package/lib/account/JoinPage.tsx +288 -0
  23. package/lib/account/LoadingView.tsx +14 -0
  24. package/lib/account/LoginPage.stories.tsx +318 -0
  25. package/lib/account/LoginPage.tsx +138 -0
  26. package/lib/account/LoginView.tsx +136 -0
  27. package/lib/account/NoConnectionView.tsx +34 -0
  28. package/lib/account/PasswordResetPage.stories.tsx +250 -0
  29. package/lib/account/PasswordResetPage.tsx +291 -0
  30. package/lib/account/UserMismatchView.tsx +61 -0
  31. package/lib/account/VerifyPage.tsx +217 -0
  32. package/lib/account/style.css.ts +57 -0
  33. package/lib/account/types.ts +9 -0
  34. package/lib/account/useCurrentUserResult.tsx +38 -0
  35. package/lib/class-names.ts +1 -1
  36. package/lib/client.ts +54 -7
  37. package/lib/globals.css.ts +5 -0
  38. package/lib/index.ts +11 -1
  39. package/lib/useEnsureValue.ts +31 -0
  40. package/package.json +3 -2
  41. package/lib/ClientContext/ClientContext.tsx +0 -25
  42. package/lib/LoginPage/LoginPage.stories.tsx +0 -107
  43. package/lib/LoginPage/LoginPage.tsx +0 -204
  44. package/lib/LoginPage/style.css.ts +0 -17
@@ -0,0 +1,217 @@
1
+ import { Button, Form } from "@ariakit/react";
2
+ import { useState, type Dispatch, type SetStateAction } from "react";
3
+ import { Link } from "wouter";
4
+ import { useAppConfig, useClient } from "../AppConfig/AppConfig.tsx";
5
+ import { getSubmitFailureMessage } from "../failureMessages.ts";
6
+ import {
7
+ Letterhead,
8
+ LetterheadHeading,
9
+ LetterheadParagraph,
10
+ LetterheadSubmitButton,
11
+ } from "../Letterhead/index.tsx";
12
+ import { button } from "../Letterhead/style.css.ts";
13
+ import {
14
+ LetterheadFormActions,
15
+ LetterheadHeader,
16
+ LetterheadSubmitError,
17
+ LetterheadTextField,
18
+ } from "../LetterheadForm/index.tsx";
19
+ import type { CurrentUser } from "../types.ts";
20
+ import { useForm } from "../use-form.ts";
21
+ import type { EventHandler } from "./types.ts";
22
+
23
+ type SetStep = Dispatch<SetStateAction<Steps>>;
24
+
25
+ function InitialStep(props: { setStep: SetStep; currentUser: CurrentUser }) {
26
+ const client = useClient();
27
+ const { email } = props.currentUser;
28
+ const { form, submitName } = useForm({
29
+ defaultValues: {},
30
+ async onSubmit() {
31
+ const op = await client.requestUserVerification();
32
+ return op.mapFailure(getSubmitFailureMessage);
33
+ },
34
+ onSuccess(value) {
35
+ props.setStep({ type: "SUBMIT_CODE", tokenId: value.tokenId });
36
+ },
37
+ });
38
+
39
+ return (
40
+ <Form store={form}>
41
+ <Letterhead>
42
+ <LetterheadHeader>
43
+ <LetterheadHeading>Verify account</LetterheadHeading>
44
+ <LetterheadParagraph>
45
+ Your account is currently not verified. We will send a one-time code
46
+ to your email ({email}) to complete the verification.
47
+ </LetterheadParagraph>
48
+ </LetterheadHeader>
49
+
50
+ <LetterheadFormActions>
51
+ <LetterheadSubmitError name={submitName} />
52
+ <LetterheadSubmitButton>Send code</LetterheadSubmitButton>
53
+ </LetterheadFormActions>
54
+ </Letterhead>
55
+ </Form>
56
+ );
57
+ }
58
+
59
+ function SubmitCodeStep(props: {
60
+ tokenId: string;
61
+ setStep: SetStep;
62
+ currentUser: CurrentUser;
63
+ }) {
64
+ const client = useClient();
65
+ const { email } = props.currentUser;
66
+ const { form, submitName } = useForm({
67
+ defaultValues: {
68
+ code: "",
69
+ },
70
+ async onSubmit({ values }) {
71
+ const op = await client.verifyUser({
72
+ ...values,
73
+ tokenId: props.tokenId,
74
+ });
75
+
76
+ return op.mapFailure((failure) => {
77
+ return getSubmitFailureMessage(failure, {
78
+ 404: "🚫 This code is incorrect or expired. Please try again.",
79
+ });
80
+ });
81
+ },
82
+ onSuccess() {
83
+ props.setStep({ type: "SUCCESS" });
84
+ },
85
+ });
86
+
87
+ return (
88
+ <Form store={form}>
89
+ <Letterhead>
90
+ <LetterheadHeader>
91
+ <LetterheadHeading>Verify Account</LetterheadHeading>
92
+ <LetterheadParagraph>
93
+ We've sent a one-time code to {email}. Please, enter the code in the
94
+ field below to complete verification.
95
+ </LetterheadParagraph>
96
+ </LetterheadHeader>
97
+
98
+ <LetterheadTextField
99
+ name={form.names.code}
100
+ label="Code"
101
+ placeholder="E.g. 123123"
102
+ autoComplete="one-time-code"
103
+ required
104
+ />
105
+
106
+ <LetterheadFormActions>
107
+ <LetterheadSubmitError name={submitName} />
108
+ <LetterheadSubmitButton>Verify</LetterheadSubmitButton>
109
+ </LetterheadFormActions>
110
+ </Letterhead>
111
+ </Form>
112
+ );
113
+ }
114
+
115
+ function SuccessStep(props: { onClose?: EventHandler }) {
116
+ const { onClose } = props;
117
+ const { hrefs } = useAppConfig();
118
+
119
+ return (
120
+ <Letterhead>
121
+ <LetterheadHeader>
122
+ <LetterheadHeading>Success!</LetterheadHeading>
123
+ <LetterheadParagraph>
124
+ Your Indie Tabletop Club account has been verified. Yay!
125
+ </LetterheadParagraph>
126
+ </LetterheadHeader>
127
+
128
+ {onClose ? (
129
+ <Button onClick={() => onClose()} className={button()}>
130
+ Done
131
+ </Button>
132
+ ) : (
133
+ <Link href={hrefs.dashboard()} className={button()}>
134
+ Go to dashboard
135
+ </Link>
136
+ )}
137
+ </Letterhead>
138
+ );
139
+ }
140
+
141
+ type Steps =
142
+ | { type: "INITIAL" }
143
+ | { type: "SUBMIT_CODE"; tokenId: string }
144
+ | { type: "SUCCESS" };
145
+
146
+ export function VerifyAccountView(props: {
147
+ currentUser: CurrentUser;
148
+
149
+ /**
150
+ * If provided, will cause the success step to render a close dialog button
151
+ * instead of "Go to Dashboard" button.
152
+ *
153
+ * This is useful if this view is used within a modal dialog context.
154
+ */
155
+ onClose?: EventHandler;
156
+ }) {
157
+ const { currentUser } = props;
158
+ const [step, setStep] = useState<Steps>({ type: "INITIAL" });
159
+
160
+ switch (step.type) {
161
+ case "INITIAL": {
162
+ return <InitialStep setStep={setStep} currentUser={currentUser} />;
163
+ }
164
+
165
+ case "SUBMIT_CODE": {
166
+ return (
167
+ <SubmitCodeStep {...step} setStep={setStep} currentUser={currentUser} />
168
+ );
169
+ }
170
+
171
+ case "SUCCESS": {
172
+ return <SuccessStep {...props} />;
173
+ }
174
+ }
175
+ }
176
+
177
+ // TODO: Decide if to remove this?
178
+ function AlreadyVerifiedView() {
179
+ const { hrefs } = useAppConfig();
180
+
181
+ return (
182
+ <Letterhead>
183
+ <LetterheadHeader>
184
+ <LetterheadHeading>Already Verified</LetterheadHeading>
185
+ <LetterheadParagraph>
186
+ Your user account has already been verified. You're all good!
187
+ </LetterheadParagraph>
188
+ </LetterheadHeader>
189
+
190
+ <Link href={hrefs.dashboard()} className={button()}>
191
+ Back to dashboard
192
+ </Link>
193
+ </Letterhead>
194
+ );
195
+ }
196
+
197
+ // TODO: Decide if to remove this?
198
+ export function VerifyCard(props: { currentUser: CurrentUser | null }) {
199
+ const { currentUser } = props;
200
+
201
+ if (!currentUser) {
202
+ return (
203
+ <Letterhead>
204
+ <LetterheadHeading>Cannot verify</LetterheadHeading>
205
+ <LetterheadParagraph>
206
+ You must be logged in to verify your account.
207
+ </LetterheadParagraph>
208
+ </Letterhead>
209
+ );
210
+ }
211
+
212
+ if (currentUser.isVerified) {
213
+ return <AlreadyVerifiedView />;
214
+ }
215
+
216
+ return <VerifyAccountView currentUser={currentUser} />;
217
+ }
@@ -0,0 +1,57 @@
1
+ import { style } from "@vanilla-extract/css";
2
+ import { Hover, MinWidth } from "../media.ts";
3
+
4
+ export const page = style({
5
+ backgroundColor: "white",
6
+
7
+ "@media": {
8
+ [MinWidth.SMALL]: {
9
+ backgroundColor: "transparent",
10
+ padding: "clamp(1rem, 5vw, 5rem)",
11
+ },
12
+ },
13
+ });
14
+
15
+ export const accountPicker = {
16
+ container: style({
17
+ backgroundColor: "#fafafa",
18
+ borderRadius: "0.5rem",
19
+ }),
20
+
21
+ button: style({
22
+ padding: "1rem 1.5rem",
23
+ textAlign: "start",
24
+ inlineSize: "100%",
25
+ borderRadius: "inherit",
26
+
27
+ "@media": {
28
+ [Hover.HOVER]: {
29
+ transition: "200ms background-color",
30
+
31
+ ":hover": {
32
+ backgroundColor: "hsl(from #fafafa h s calc(l - 3))",
33
+ },
34
+ },
35
+ },
36
+ }),
37
+
38
+ buttonLabel: style({
39
+ fontWeight: 600,
40
+ fontSize: "1.125rem",
41
+ }),
42
+
43
+ divider: style({
44
+ marginInline: "1.5rem",
45
+ backgroundColor: "#e2e2e2",
46
+ blockSize: "1px",
47
+ }),
48
+ };
49
+
50
+ export const loadingView = {
51
+ container: style({
52
+ display: "flex",
53
+ justifyContent: "center",
54
+ alignItems: "center",
55
+ height: "12rem",
56
+ }),
57
+ };
@@ -0,0 +1,9 @@
1
+ export type EventHandler = () => Promise<void> | void;
2
+
3
+ export type EventHandlerWithReload = (options: {
4
+ reload: () => void;
5
+ }) => Promise<void> | void;
6
+
7
+ export type DefaultFormValues = {
8
+ email?: string;
9
+ };
@@ -0,0 +1,38 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useClient } from "../AppConfig/AppConfig.tsx";
3
+ import { type Failure, Pending, type Success } from "../async-op.ts";
4
+ import type { CurrentUser, FailurePayload } from "../types.ts";
5
+
6
+ export function useCurrentUserResult(options?: {
7
+ /**
8
+ * Optionally disable immediate fetch action.
9
+ *
10
+ * @default true
11
+ */
12
+ performFetch?: boolean;
13
+ }) {
14
+ const { performFetch = true } = options ?? {};
15
+ const client = useClient();
16
+ const [latestAttemptTs, setLatestAttemptTs] = useState(Date.now());
17
+
18
+ const [result, setResult] = useState<
19
+ Success<CurrentUser> | Failure<FailurePayload> | Pending
20
+ >(new Pending());
21
+
22
+ // We are intentionally not using SWR in this case, as we don't want to deal
23
+ // with caching or any of that business.
24
+ useEffect(() => {
25
+ if (performFetch) {
26
+ client.getCurrentUser().then((result) => setResult(result));
27
+ }
28
+ }, [client, latestAttemptTs, performFetch]);
29
+
30
+ return {
31
+ result,
32
+ latestAttemptTs,
33
+ reload: () => {
34
+ setResult(new Pending());
35
+ setLatestAttemptTs(Date.now());
36
+ },
37
+ };
38
+ }
@@ -23,7 +23,7 @@ export function classNames(...classNames: ClassName[]) {
23
23
  * be filtered out.
24
24
  *
25
25
  * @example
26
- * <h1 {...clx(props, 'heading', 'bold')}>Hello world!</h1>
26
+ * <h1 {...cx(props, 'heading', 'bold')}>Hello world!</h1>
27
27
  */
28
28
 
29
29
  export function cx(...cns: ClassName[]) {
package/lib/client.ts CHANGED
@@ -3,6 +3,15 @@ import { Failure, Success } from "./async-op.js";
3
3
  import { currentUser, redeemedPledge, sessionInfo } from "./structs.js";
4
4
  import type { CurrentUser, FailurePayload, SessionInfo } from "./types.js";
5
5
 
6
+ const logLevelToInt = {
7
+ off: 0,
8
+ error: 1,
9
+ warn: 2,
10
+ info: 3,
11
+ };
12
+
13
+ type LogLevel = keyof typeof logLevelToInt;
14
+
6
15
  export class IndieTabletopClient {
7
16
  origin: string;
8
17
  private onCurrentUser?: (currentUser: CurrentUser) => void;
@@ -11,6 +20,7 @@ export class IndieTabletopClient {
11
20
  private refreshTokenPromise?: Promise<
12
21
  Success<{ sessionInfo: SessionInfo }> | Failure<FailurePayload>
13
22
  >;
23
+ private maxLogLevel: number;
14
24
 
15
25
  constructor(props: {
16
26
  apiOrigin: string;
@@ -31,11 +41,28 @@ export class IndieTabletopClient {
31
41
  * Runs when token refresh is attempted, but fails due to 401 error.
32
42
  */
33
43
  onSessionExpired?: () => void;
44
+
45
+ /**
46
+ * Controls how much to log to the console.
47
+ *
48
+ * This is useful e.g. in Storybook, where errors are reported by MSW, and
49
+ * we don't want to pollute the console with duplicate data.
50
+ *
51
+ * @default 'info'
52
+ */
53
+ logLevel?: LogLevel;
34
54
  }) {
35
55
  this.origin = props.apiOrigin;
36
56
  this.onCurrentUser = props.onCurrentUser;
37
57
  this.onSessionInfo = props.onSessionInfo;
38
58
  this.onSessionExpired = props.onSessionExpired;
59
+ this.maxLogLevel = props.logLevel ? logLevelToInt[props.logLevel] : 1;
60
+ }
61
+
62
+ private log(level: Exclude<LogLevel, "off">, ...messages: unknown[]) {
63
+ if (logLevelToInt[level] <= this.maxLogLevel) {
64
+ console[level](...messages);
65
+ }
39
66
  }
40
67
 
41
68
  protected async fetch<T, S>(
@@ -63,7 +90,6 @@ export class IndieTabletopClient {
63
90
  });
64
91
 
65
92
  if (!res.ok) {
66
- console.error(res);
67
93
  return new Failure({ type: "API_ERROR", code: res.status });
68
94
  }
69
95
 
@@ -71,12 +97,12 @@ export class IndieTabletopClient {
71
97
  const data = mask(await res.json(), struct);
72
98
  return new Success(data);
73
99
  } catch (error) {
74
- console.error(error);
100
+ this.log("error", error);
75
101
 
76
102
  return new Failure({ type: "VALIDATION_ERROR" });
77
103
  }
78
104
  } catch (error) {
79
- console.error(error);
105
+ this.log("error", error);
80
106
 
81
107
  if (error instanceof Error) {
82
108
  return new Failure({ type: "NETWORK_ERROR" });
@@ -101,15 +127,15 @@ export class IndieTabletopClient {
101
127
  }
102
128
 
103
129
  if (op.failure.type === "API_ERROR" && op.failure.code === 401) {
104
- console.info("API request failed with error 401. Refreshing tokens.");
130
+ this.log("info", "API request failed with error 401. Refreshing tokens.");
105
131
 
106
132
  const refreshOp = await this.refreshTokens();
107
133
 
108
134
  if (refreshOp.isSuccess) {
109
- console.info("Tokens refreshed. Retrying request.");
135
+ this.log("info", "Tokens refreshed. Retrying request.");
110
136
  return await this.fetch(path, struct, init);
111
137
  } else {
112
- console.info("Could not refresh tokens.");
138
+ this.log("info", "Could not refresh tokens.");
113
139
  }
114
140
  }
115
141
 
@@ -189,7 +215,7 @@ export class IndieTabletopClient {
189
215
  const ongoingRequest = this.refreshTokenPromise;
190
216
 
191
217
  if (ongoingRequest) {
192
- console.info("Token refresh ongoing. Reusing existing promise.");
218
+ this.log("info", "Token refresh ongoing. Reusing existing promise.");
193
219
  return await ongoingRequest;
194
220
  }
195
221
 
@@ -354,4 +380,25 @@ export class IndieTabletopClient {
354
380
  { method: "POST", json: { pledgeId, type: "PLEDGE" } },
355
381
  );
356
382
  }
383
+
384
+ async subscribeToNewsletterByEmail(newsletterCode: string, email: string) {
385
+ return await this.fetch(
386
+ `/v1/newsletters/${newsletterCode}/subscriptions`,
387
+ object({ message: string(), tokenId: string() }),
388
+ { method: "POST", json: { type: "EMAIL", email } },
389
+ );
390
+ }
391
+
392
+ async confirmNewsletterSignup(
393
+ newsletterCode: string,
394
+ tokenId: string,
395
+ plaintextCode: string,
396
+ ) {
397
+ const queryParams = new URLSearchParams({ plaintextCode });
398
+ return await this.fetch(
399
+ `/v1/newsletters/${newsletterCode}/newsletter-signup-tokens/${tokenId}?${queryParams}`,
400
+ object({ message: string() }),
401
+ { method: "PUT" },
402
+ );
403
+ }
357
404
  }
@@ -3,7 +3,12 @@ import { globalStyle } from "@vanilla-extract/css";
3
3
  // Apply global vars
4
4
  import "./vars.css.ts";
5
5
 
6
+ const futureCss = {
7
+ interpolateSize: "allow-keywords",
8
+ };
9
+
6
10
  globalStyle(":root", {
11
+ ...futureCss,
7
12
  fontSynthesis: "none",
8
13
  textRendering: "optimizeLegibility",
9
14
  WebkitFontSmoothing: "antialiased",
package/lib/index.ts CHANGED
@@ -1,16 +1,24 @@
1
1
  // Components
2
- export * from "./ClientContext/ClientContext.tsx";
2
+ export * from "./account/CurrentUserFetcher.tsx";
3
+ export * from "./account/JoinPage.tsx";
4
+ export * from "./account/LoginPage.tsx";
5
+ export * from "./account/PasswordResetPage.tsx";
6
+ export * from "./AppConfig/AppConfig.tsx";
3
7
  export * from "./DialogTrigger/index.tsx";
4
8
  export * from "./ExternalLink.tsx";
5
9
  export * from "./FormSubmitButton.tsx";
6
10
  export * from "./FullscreenDismissBlocker.tsx";
11
+ export * from "./IndieTabletopClubLogo.tsx";
7
12
  export * from "./IndieTabletopClubSymbol.tsx";
8
13
  export * from "./Letterhead/index.tsx";
9
14
  export * from "./LetterheadForm/index.tsx";
10
15
  export * from "./LoadingIndicator.tsx";
11
16
  export * from "./ModalDialog/index.tsx";
17
+ export * from "./QRCode/QRCode.tsx";
12
18
  export * from "./ReleaseInfo/index.tsx";
13
19
  export * from "./ServiceWorkerHandler.tsx";
20
+ export * from "./ShareButton/ShareButton.tsx";
21
+ export * from "./SubscribeCard/SubscribeByEmailCard.tsx";
14
22
  export * from "./SubscribeCard/SubscribeCard.tsx";
15
23
 
16
24
  // Hooks
@@ -22,6 +30,7 @@ export * from "./use-is-installed.ts";
22
30
  export * from "./use-media-query.ts";
23
31
  export * from "./use-reverting-state.ts";
24
32
  export * from "./use-scroll-restoration.ts";
33
+ export * from "./useEnsureValue.ts";
25
34
  export * from "./useIsVisible.ts";
26
35
 
27
36
  // Utils
@@ -33,6 +42,7 @@ export * from "./client.ts";
33
42
  export * from "./copyrightRange.ts";
34
43
  export * from "./failureMessages.ts";
35
44
  export * from "./groupBy.ts";
45
+ export * from "./HistoryState.ts";
36
46
  export * from "./ids.ts";
37
47
  export * from "./media.ts";
38
48
  export * from "./random.ts";
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Failure, Pending, Success, type AsyncOp } from "./async-op.ts";
3
+ import { caughtValueToString } from "./caught-value.ts";
4
+
5
+ /**
6
+ * Checks if the provided value is non-nullish, otherwise calls provided
7
+ * getValue function to obtain the value (presumably from some remote location).
8
+ */
9
+ export function useEnsureValue<T>(
10
+ value: T | null | undefined,
11
+ getValue: () => Promise<Success<T> | Failure<string>>,
12
+ ) {
13
+ const [result, setShareLink] = useState<AsyncOp<T, string>>(
14
+ value != null ? new Success(value) : new Pending(),
15
+ );
16
+
17
+ useEffect(() => {
18
+ if (value == null) {
19
+ getValue().then(
20
+ (success) => {
21
+ setShareLink(success);
22
+ },
23
+ (error: unknown) => {
24
+ setShareLink(new Failure(caughtValueToString(error)));
25
+ },
26
+ );
27
+ }
28
+ }, [value, getValue]);
29
+
30
+ return result;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "3.6.0-1",
3
+ "version": "3.6.0-3",
4
4
  "description": "A collection of modules used in apps built by Indie Tabletop Club",
5
5
  "private": false,
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  "build": "tsc",
10
10
  "dev": "tsc --watch",
11
11
  "test": "vitest",
12
- "storybook": "storybook dev -p 6000"
12
+ "storybook": "storybook dev"
13
13
  },
14
14
  "exports": {
15
15
  ".": "./lib/index.ts",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@storybook/addon-docs": "^9.1.6",
29
+ "@storybook/addon-links": "^9.1.7",
29
30
  "@storybook/react-vite": "^9.1.6",
30
31
  "@types/react": "^19.1.8",
31
32
  "msw": "^2.11.2",
@@ -1,25 +0,0 @@
1
- import { createContext, type ReactNode, useContext } from "react";
2
- import type { IndieTabletopClient } from "../client.ts";
3
-
4
- const ClientContext = createContext<IndieTabletopClient | null>(null);
5
-
6
- export function ClientProvider(props: {
7
- client: IndieTabletopClient;
8
- children: ReactNode;
9
- }) {
10
- const { client, children } = props;
11
- return <ClientContext value={client}>{children}</ClientContext>;
12
- }
13
-
14
- export function useClient() {
15
- const client = useContext(ClientContext);
16
-
17
- if (!client) {
18
- throw new Error(
19
- `Attempting to retrieve ITC client, but none was found within context. ` +
20
- `Make sure that ClientProvider is used in the component hierarchy.`,
21
- );
22
- }
23
-
24
- return client;
25
- }