@indietabletop/appkit 4.0.0-0 → 4.0.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.
@@ -4,6 +4,7 @@ import type { AppHrefs } from "../hrefs.ts";
4
4
 
5
5
  export type AppConfig = {
6
6
  appName: string;
7
+ isDev: boolean;
7
8
  client: IndieTabletopClient;
8
9
  hrefs: AppHrefs;
9
10
  placeholders: {
@@ -1,9 +1,10 @@
1
1
  import { useAppConfig } from "../AppConfig/AppConfig.tsx";
2
2
 
3
3
  export function DocumentTitle(props: { children: string }) {
4
- const { children: title } = props;
5
- const { appName } = useAppConfig();
4
+ const { children: children } = props;
5
+ const { appName, isDev } = useAppConfig();
6
6
  const itc = `${appName} · Indie Tabletop Club`;
7
+ const title = children ? `${children} | ${itc}` : itc;
7
8
 
8
- return <title>{title ? `${title} | ${itc}` : itc}</title>;
9
+ return <title>{isDev ? `[DEV] ${title}` : title}</title>;
9
10
  }
@@ -119,6 +119,8 @@ export function CurrentUserFetcher(props: CurrentUserFetcherProps) {
119
119
  <VerifyAccountView
120
120
  currentUser={serverUser}
121
121
  onClose={() => setOpen(false)}
122
+ onLogout={onLogout}
123
+ reload={reload}
122
124
  />
123
125
  </ModalDialog>
124
126
 
@@ -2,6 +2,7 @@ import { Button, Form } from "@ariakit/react";
2
2
  import { useState, type Dispatch, type SetStateAction } from "react";
3
3
  import { Link } from "wouter";
4
4
  import { useAppConfig, useClient } from "../AppConfig/AppConfig.tsx";
5
+ import { interactiveText } from "../common.css.ts";
5
6
  import { getSubmitFailureMessage } from "../failureMessages.ts";
6
7
  import {
7
8
  Letterhead,
@@ -18,13 +19,20 @@ import {
18
19
  } from "../LetterheadForm/index.tsx";
19
20
  import type { CurrentUser } from "../types.ts";
20
21
  import { useForm } from "../use-form.ts";
21
- import type { EventHandler } from "./types.ts";
22
+ import type { EventHandler, EventHandlerWithReload } from "./types.ts";
22
23
 
23
- type SetStep = Dispatch<SetStateAction<Steps>>;
24
+ type SetStep = Dispatch<SetStateAction<VerifyStep>>;
25
+
26
+ function InitialStep(props: {
27
+ setStep: SetStep;
28
+ currentUser: CurrentUser;
29
+ onLogout: EventHandlerWithReload;
30
+ reload: () => void;
31
+ }) {
32
+ const { setStep, onLogout, currentUser, reload } = props;
33
+ const { email } = currentUser;
24
34
 
25
- function InitialStep(props: { setStep: SetStep; currentUser: CurrentUser }) {
26
35
  const client = useClient();
27
- const { email } = props.currentUser;
28
36
  const { form, submitName } = useForm({
29
37
  defaultValues: {},
30
38
  async onSubmit() {
@@ -32,7 +40,7 @@ function InitialStep(props: { setStep: SetStep; currentUser: CurrentUser }) {
32
40
  return op.mapFailure(getSubmitFailureMessage);
33
41
  },
34
42
  onSuccess(value) {
35
- props.setStep({ type: "SUBMIT_CODE", tokenId: value.tokenId });
43
+ setStep({ type: "SUBMIT_CODE", tokenId: value.tokenId });
36
44
  },
37
45
  });
38
46
 
@@ -50,6 +58,16 @@ function InitialStep(props: { setStep: SetStep; currentUser: CurrentUser }) {
50
58
  <LetterheadFormActions>
51
59
  <LetterheadSubmitError name={submitName} />
52
60
  <LetterheadSubmitButton>Send code</LetterheadSubmitButton>
61
+ <LetterheadParagraph>
62
+ Cannot complete verification?{" "}
63
+ <Button
64
+ className={interactiveText}
65
+ onClick={() => void onLogout({ reload })}
66
+ >
67
+ Log out
68
+ </Button>
69
+ .
70
+ </LetterheadParagraph>
53
71
  </LetterheadFormActions>
54
72
  </Letterhead>
55
73
  </Form>
@@ -138,13 +156,15 @@ function SuccessStep(props: { onClose?: EventHandler }) {
138
156
  );
139
157
  }
140
158
 
141
- type Steps =
159
+ type VerifyStep =
142
160
  | { type: "INITIAL" }
143
161
  | { type: "SUBMIT_CODE"; tokenId: string }
144
162
  | { type: "SUCCESS" };
145
163
 
146
164
  export function VerifyAccountView(props: {
147
165
  currentUser: CurrentUser;
166
+ onLogout: EventHandlerWithReload;
167
+ reload: () => void;
148
168
 
149
169
  /**
150
170
  * If provided, will cause the success step to render a close dialog button
@@ -154,18 +174,15 @@ export function VerifyAccountView(props: {
154
174
  */
155
175
  onClose?: EventHandler;
156
176
  }) {
157
- const { currentUser } = props;
158
- const [step, setStep] = useState<Steps>({ type: "INITIAL" });
177
+ const [step, setStep] = useState<VerifyStep>({ type: "INITIAL" });
159
178
 
160
179
  switch (step.type) {
161
180
  case "INITIAL": {
162
- return <InitialStep setStep={setStep} currentUser={currentUser} />;
181
+ return <InitialStep {...props} setStep={setStep} />;
163
182
  }
164
183
 
165
184
  case "SUBMIT_CODE": {
166
- return (
167
- <SubmitCodeStep {...step} setStep={setStep} currentUser={currentUser} />
168
- );
185
+ return <SubmitCodeStep {...props} {...step} setStep={setStep} />;
169
186
  }
170
187
 
171
188
  case "SUCCESS": {
@@ -173,45 +190,3 @@ export function VerifyAccountView(props: {
173
190
  }
174
191
  }
175
192
  }
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
- }
@@ -23,7 +23,22 @@ export function useCurrentUserResult(options?: {
23
23
  // with caching or any of that business.
24
24
  useEffect(() => {
25
25
  if (performFetch) {
26
- client.getCurrentUser().then((result) => setResult(result));
26
+ const fetchUserAndStoreResult = async () => {
27
+ setResult(new Pending());
28
+
29
+ const result = await client.getCurrentUser();
30
+ setResult(result);
31
+ };
32
+
33
+ // Invoke the fetch action
34
+ fetchUserAndStoreResult();
35
+
36
+ // Set up listeners for data revalidation
37
+ window.addEventListener("focus", fetchUserAndStoreResult);
38
+
39
+ return () => {
40
+ window.removeEventListener("focus", fetchUserAndStoreResult);
41
+ };
27
42
  }
28
43
  }, [client, latestAttemptTs, performFetch]);
29
44
 
@@ -1,6 +1,10 @@
1
1
  export function copyrightRange(yearSince: number) {
2
2
  const currentYear = new Date().getFullYear();
3
- return currentYear === yearSince
4
- ? ${yearSince}`
5
- : ${yearSince}–${currentYear}`;
3
+
4
+ // Handle edge-case in which yearSince is greater than currentYear.
5
+ const clampedYearSince = Math.min(yearSince, currentYear);
6
+
7
+ return currentYear === clampedYearSince
8
+ ? `© ${clampedYearSince}`
9
+ : `© ${clampedYearSince}–${currentYear}`;
6
10
  }
package/lib/types.ts CHANGED
@@ -7,6 +7,23 @@ type Brand<B> = { __brand: B };
7
7
 
8
8
  export type Branded<T, B> = T & Brand<B>;
9
9
 
10
+ /**
11
+ * Make properties in union K required in T.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * interface User {
16
+ * id: string;
17
+ * name?: string;
18
+ * email?: string;
19
+ * }
20
+ *
21
+ * type UserWithRequiredName = RequiredPick<User, 'name'>;
22
+ * // Result: { id: string; name: string; email?: string; }
23
+ * ```
24
+ */
25
+ export type RequiredPick<T, K extends keyof T> = T & Required<Pick<T, K>>;
26
+
10
27
  /**
11
28
  * A branded string.
12
29
  *
package/lib/utm.ts CHANGED
@@ -84,6 +84,9 @@ export function utm(params: UtmParams) {
84
84
  */
85
85
  export function createUtm(defaults: UtmParams) {
86
86
  return function (params?: Partial<UtmParams>) {
87
- return utm({ ...defaults, ...params });
87
+ return utm({
88
+ ...defaults,
89
+ ...(params && omitUndefinedKeys(params)),
90
+ });
88
91
  };
89
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "4.0.0-0",
3
+ "version": "4.0.0-1",
4
4
  "description": "A collection of modules used in apps built by Indie Tabletop Club",
5
5
  "private": false,
6
6
  "type": "module",
@@ -25,17 +25,17 @@
25
25
  "react": "^18.0.0 || ^19.0.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@storybook/addon-docs": "^9.1.6",
29
- "@storybook/addon-links": "^9.1.7",
30
- "@storybook/react-vite": "^9.1.6",
28
+ "@storybook/addon-docs": "^9.1.10",
29
+ "@storybook/addon-links": "^9.1.10",
30
+ "@storybook/react-vite": "^9.1.10",
31
31
  "@types/react": "^19.1.8",
32
32
  "msw": "^2.11.2",
33
33
  "msw-storybook-addon": "^2.0.5",
34
34
  "np": "^10.1.0",
35
- "storybook": "^9.1.6",
35
+ "storybook": "^9.1.10",
36
36
  "typescript": "^5.8.2",
37
37
  "vite": "^6.3.5",
38
- "vitest": "^3.0.5"
38
+ "vitest": "^3.2.4"
39
39
  },
40
40
  "dependencies": {
41
41
  "@ariakit/react": "^0.4.17",
@@ -1,29 +0,0 @@
1
- import { describe, expect, test } from "vitest";
2
- import {
3
- appendCopyToText,
4
- maybeAppendCopyToText,
5
- } from "./append-copy-to-text.ts";
6
-
7
- describe("appendCopyToText", () => {
8
- test("Appends ' (Copy)' to provided string", () => {
9
- const returnValue = appendCopyToText("Zangrad Raiders");
10
- expect(returnValue).toBe("Zangrad Raiders (Copy)");
11
- });
12
-
13
- test("Adds a copy count number if string already ends in ' (Copy)'", () => {
14
- const returnValue = appendCopyToText("Zangrad Raiders (Copy)");
15
- expect(returnValue).toBe("Zangrad Raiders (Copy 2)");
16
- });
17
-
18
- test("Increments a copy count number if one already exists", () => {
19
- const returnValue = appendCopyToText("Zangrad Raiders (Copy 2)");
20
- expect(returnValue).toBe("Zangrad Raiders (Copy 3)");
21
- });
22
- });
23
-
24
- describe("maybeAppendCopyToText", () => {
25
- test("Ignores empty strings", () => {
26
- const returnValue = maybeAppendCopyToText("");
27
- expect(returnValue).toBe("");
28
- });
29
- });
@@ -1,169 +0,0 @@
1
- import { describe, expect, test } from "vitest";
2
- import {
3
- getFetchFailureMessages,
4
- getSubmitFailureMessage,
5
- } from "./failureMessages.ts";
6
-
7
- describe("getFetchFailureMessages", () => {
8
- test("Returns correct message for API_ERROR with code 404", () => {
9
- const result = getFetchFailureMessages({ type: "API_ERROR", code: 404 });
10
-
11
- expect(result).toMatchInlineSnapshot(`
12
- {
13
- "action": {
14
- "href": "~/",
15
- "label": "Go back",
16
- "type": "LINK",
17
- },
18
- "description": "The link you have followed might be broken.",
19
- "title": "Not found",
20
- }
21
- `);
22
- });
23
-
24
- test("Returns correct message for API_ERROR with code 500", () => {
25
- const result = getFetchFailureMessages({ type: "API_ERROR", code: 500 });
26
-
27
- expect(result).toMatchInlineSnapshot(`
28
- {
29
- "action": {
30
- "label": "Reload app",
31
- "type": "RELOAD",
32
- },
33
- "description": "This is probably an issue with our servers. You can try refreshing.",
34
- "title": "Ooops, something went wrong",
35
- }
36
- `);
37
- });
38
-
39
- test("Returns correct message for API_ERROR with partial override", () => {
40
- const result = getFetchFailureMessages(
41
- { type: "API_ERROR", code: 404 },
42
- { 404: { title: `Army not found` } },
43
- );
44
-
45
- expect(result).toMatchInlineSnapshot(`
46
- {
47
- "action": {
48
- "href": "~/",
49
- "label": "Go back",
50
- "type": "LINK",
51
- },
52
- "description": "The link you have followed might be broken.",
53
- "title": "Army not found",
54
- }
55
- `);
56
- });
57
-
58
- test("Returns correct message for API_ERROR with override", () => {
59
- const result = getFetchFailureMessages(
60
- { type: "API_ERROR", code: 404 },
61
- {
62
- 404: {
63
- title: `Army not found`,
64
- description: `It might have been deleted.`,
65
- },
66
- },
67
- );
68
-
69
- expect(result).toMatchInlineSnapshot(`
70
- {
71
- "action": {
72
- "href": "~/",
73
- "label": "Go back",
74
- "type": "LINK",
75
- },
76
- "description": "It might have been deleted.",
77
- "title": "Army not found",
78
- }
79
- `);
80
- });
81
-
82
- test("Returns correct message for NETWORK_ERROR", () => {
83
- const result = getFetchFailureMessages({ type: "NETWORK_ERROR" });
84
-
85
- expect(result).toMatchInlineSnapshot(`
86
- {
87
- "action": {
88
- "label": "Retry request",
89
- "type": "REFETCH",
90
- },
91
- "description": "Check your interent connection and try again.",
92
- "title": "No connection",
93
- }
94
- `);
95
- });
96
-
97
- test("Returns correct message for UNKNOWN_ERROR", () => {
98
- const result = getFetchFailureMessages({ type: "UNKNOWN_ERROR" });
99
-
100
- expect(result).toMatchInlineSnapshot(`
101
- {
102
- "action": {
103
- "label": "Reload app",
104
- "type": "RELOAD",
105
- },
106
- "description": "This is probably an issue on our side. You can try refreshing.",
107
- "title": "Ooops, something went wrong",
108
- }
109
- `);
110
- });
111
-
112
- test("Returns correct message for an unrecognised error type", () => {
113
- const result = getFetchFailureMessages({ type: "FOO" as any });
114
-
115
- expect(result).toMatchInlineSnapshot(`
116
- {
117
- "action": {
118
- "label": "Reload app",
119
- "type": "RELOAD",
120
- },
121
- "description": "This is probably an issue on our side. You can try refreshing.",
122
- "title": "Ooops, something went wrong",
123
- }
124
- `);
125
- });
126
- });
127
-
128
- describe("getSubmitFailureMessage", () => {
129
- test("Returns correct message for API_ERROR with code 500", () => {
130
- const message = getSubmitFailureMessage({ type: "API_ERROR", code: 500 });
131
- expect(message).toMatchInlineSnapshot(
132
- `"Could not submit form due to an unexpected server error. Please refresh the page and try again."`,
133
- );
134
- });
135
-
136
- test("Returns correct message for API_ERROR with override", () => {
137
- const message = getSubmitFailureMessage(
138
- { type: "API_ERROR", code: 401 },
139
- { 401: `Username and password do not match. Please try again.` },
140
- );
141
- expect(message).toMatchInlineSnapshot(
142
- `"Username and password do not match. Please try again."`,
143
- );
144
- });
145
-
146
- test("Returns correct message for NETWORK_ERROR", () => {
147
- const result = getSubmitFailureMessage({ type: "NETWORK_ERROR" });
148
-
149
- expect(result).toMatchInlineSnapshot(
150
- `"Could not submit form due to network error. Make sure you are connected to the internet and try again."`,
151
- );
152
- });
153
-
154
- test("Returns correct message for UNKNOWN_ERROR", () => {
155
- const result = getSubmitFailureMessage({ type: "UNKNOWN_ERROR" });
156
-
157
- expect(result).toMatchInlineSnapshot(
158
- `"Could not submit form due to an unexpected error. Please refresh the page and try again."`,
159
- );
160
- });
161
-
162
- test("Returns correct message for an unrecognised error type", () => {
163
- const result = getSubmitFailureMessage({ type: "FOO" as any });
164
-
165
- expect(result).toMatchInlineSnapshot(
166
- `"Could not submit form due to an unexpected error. Please refresh the page and try again."`,
167
- );
168
- });
169
- });
@@ -1,66 +0,0 @@
1
- import { describe, expect, test } from "vitest";
2
- import { mailto as emailTemplateToMailto } from "./mailto.ts";
3
-
4
- describe("emailTempalteToMailto", () => {
5
- test("correctly serializes data", () => {
6
- expect(
7
- emailTemplateToMailto(null, {
8
- body: `Hello world!\n\nI am URL escaped.`,
9
- subject: `This is a subject?`,
10
- cc: null,
11
- }),
12
- ).toMatchInlineSnapshot(
13
- `"mailto:?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F"`,
14
- );
15
- });
16
-
17
- test("includes recipient when provided", () => {
18
- expect(
19
- emailTemplateToMailto("hi@example.com", {
20
- body: `Hello world!\n\nI am URL escaped.`,
21
- subject: `This is a subject?`,
22
- cc: null,
23
- }),
24
- ).toMatchInlineSnapshot(
25
- `"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F"`,
26
- );
27
- });
28
-
29
- test("includes cc when provided", () => {
30
- expect(
31
- emailTemplateToMailto("hi@example.com", {
32
- body: `Hello world!\n\nI am URL escaped.`,
33
- subject: `This is a subject?`,
34
- cc: "cc@example.com",
35
- }),
36
- ).toMatchInlineSnapshot(
37
- `"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F&cc=cc%40example.com"`,
38
- );
39
- });
40
-
41
- test("includes bcc when provided", () => {
42
- expect(
43
- emailTemplateToMailto("hi@example.com", {
44
- body: `Hello world!\n\nI am URL escaped.`,
45
- subject: `This is a subject?`,
46
- cc: "cc@example.com",
47
- bcc: "bcc@example.com",
48
- }),
49
- ).toMatchInlineSnapshot(
50
- `"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F&cc=cc%40example.com&bcc=bcc%40example.com"`,
51
- );
52
- });
53
-
54
- test("cc and bcc allow array values", () => {
55
- expect(
56
- emailTemplateToMailto("hi@example.com", {
57
- body: `Hello world!\n\nI am URL escaped.`,
58
- subject: `This is a subject?`,
59
- cc: ["cc@example.com", "cc2@example.com"],
60
- bcc: ["bcc@example.com", "bcc2@example.com"],
61
- }),
62
- ).toMatchInlineSnapshot(
63
- `"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F&cc=cc%40example.com%2Ccc2%40example.com&bcc=bcc%40example.com%2Cbcc2%40example.com"`,
64
- );
65
- });
66
- });
@@ -1,22 +0,0 @@
1
- import { describe, expect, test } from "vitest";
2
- import { uniqueBy } from "./unique.ts";
3
-
4
- describe("unique", () => {
5
- test("Returns unique items based on getKey", () => {
6
- const result = uniqueBy(
7
- [{ id: "zxcvbn" }, { id: "qwerty" }, { id: "zxcvbn" }],
8
- (item) => item.id,
9
- );
10
-
11
- expect(result).toMatchInlineSnapshot(`
12
- [
13
- {
14
- "id": "zxcvbn",
15
- },
16
- {
17
- "id": "qwerty",
18
- },
19
- ]
20
- `);
21
- });
22
- });