@indietabletop/appkit 3.6.0-3 → 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.
Files changed (34) hide show
  1. package/lib/AppConfig/AppConfig.tsx +3 -8
  2. package/lib/AuthCard/AuthCard.stories.ts +38 -0
  3. package/lib/AuthCard/AuthCard.tsx +64 -0
  4. package/lib/AuthCard/style.css.ts +49 -0
  5. package/lib/DialogTrigger/index.tsx +2 -2
  6. package/lib/DocumentTitle/DocumentTitle.tsx +10 -0
  7. package/lib/LoadingIndicator.tsx +1 -0
  8. package/lib/MiddotSeparated/MiddotSeparated.stories.ts +32 -0
  9. package/lib/MiddotSeparated/MiddotSeparated.tsx +24 -0
  10. package/lib/MiddotSeparated/style.css.ts +10 -0
  11. package/lib/ModernIDB/bindings/factory.tsx +8 -3
  12. package/lib/account/CurrentUserFetcher.stories.tsx +0 -14
  13. package/lib/account/CurrentUserFetcher.tsx +20 -4
  14. package/lib/account/{JoinPage.stories.tsx → JoinCard.stories.tsx} +4 -10
  15. package/lib/account/{JoinPage.tsx → JoinCard.tsx} +8 -5
  16. package/lib/account/{LoginPage.stories.tsx → LoginCard.stories.tsx} +3 -5
  17. package/lib/account/{LoginPage.tsx → LoginCard.tsx} +5 -2
  18. package/lib/account/{PasswordResetPage.stories.tsx → PasswordResetCard.stories.tsx} +1 -4
  19. package/lib/account/{PasswordResetPage.tsx → PasswordResetCard.tsx} +7 -2
  20. package/lib/account/VerifyPage.tsx +29 -54
  21. package/lib/account/useCurrentUserResult.tsx +16 -1
  22. package/lib/copyrightRange.ts +7 -3
  23. package/lib/globals.css.ts +4 -0
  24. package/lib/hrefs.ts +48 -0
  25. package/lib/idToDate.ts +8 -0
  26. package/lib/index.ts +11 -3
  27. package/lib/mailto.ts +40 -0
  28. package/lib/types.ts +34 -0
  29. package/lib/utm.ts +92 -0
  30. package/package.json +6 -6
  31. package/lib/Title/index.tsx +0 -9
  32. package/lib/append-copy-to-text.test.ts +0 -29
  33. package/lib/failureMessages.test.ts +0 -169
  34. package/lib/unique.test.ts +0 -22
@@ -1,17 +1,12 @@
1
1
  import { createContext, type ReactNode, useContext } from "react";
2
2
  import type { IndieTabletopClient } from "../client.ts";
3
+ import type { AppHrefs } from "../hrefs.ts";
3
4
 
4
5
  export type AppConfig = {
5
6
  appName: string;
7
+ isDev: boolean;
6
8
  client: IndieTabletopClient;
7
- hrefs: {
8
- login: () => string;
9
- password: () => string;
10
- join: () => string;
11
- terms: () => string;
12
- verify: () => string;
13
- dashboard: () => string;
14
- };
9
+ hrefs: AppHrefs;
15
10
  placeholders: {
16
11
  email: string;
17
12
  };
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { fn } from "storybook/test";
3
+ import { AuthCard } from "./AuthCard.tsx";
4
+
5
+ const meta = {
6
+ title: "Account/Auth Card",
7
+ component: AuthCard,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ onLogout: fn(),
11
+ },
12
+ } satisfies Meta<typeof AuthCard>;
13
+
14
+ export default meta;
15
+
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ /**
19
+ * The default case where elements are correctly separated with middots.
20
+ */
21
+ export const Authenticated: Story = {
22
+ args: {
23
+ currentUser: {
24
+ id: "martin",
25
+ email: "martin@example.com",
26
+ isVerified: true,
27
+ },
28
+ },
29
+ };
30
+
31
+ /**
32
+ * The default case in which all steps of the flow succeed.
33
+ */
34
+ export const Anonymous: Story = {
35
+ args: {
36
+ currentUser: null,
37
+ },
38
+ };
@@ -0,0 +1,64 @@
1
+ import { Button } from "@ariakit/react";
2
+ import { Link } from "wouter";
3
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
4
+ import { interactiveText } from "../common.css.ts";
5
+ import { IndieTabletopClubLogo } from "../IndieTabletopClubLogo.tsx";
6
+ import { LetterheadParagraph } from "../Letterhead/index.tsx";
7
+ import { button } from "../Letterhead/style.css.ts";
8
+ import { MiddotSeparated } from "../MiddotSeparated/MiddotSeparated.tsx";
9
+ import type { CurrentUser } from "../types.ts";
10
+ import { card } from "./style.css.ts";
11
+
12
+ /**
13
+ * Small, ITC-branded card that shows authentication status.
14
+ *
15
+ * Primarily intended to be used within the sidenav.
16
+ */
17
+ export function AuthCard(props: {
18
+ onLogout: () => void;
19
+ currentUser: CurrentUser | null;
20
+ }) {
21
+ const { currentUser, onLogout } = props;
22
+ const { hrefs } = useAppConfig();
23
+ const align = !currentUser ? "center" : "start";
24
+
25
+ return (
26
+ <div className={card.container({ align })}>
27
+ <IndieTabletopClubLogo className={card.logo({ align })} />
28
+
29
+ {currentUser ? (
30
+ <>
31
+ <LetterheadParagraph>{currentUser.email}</LetterheadParagraph>
32
+
33
+ <MiddotSeparated className={card.loggedInAction}>
34
+ <Link className={interactiveText} href={hrefs.account()}>
35
+ Account
36
+ </Link>
37
+
38
+ <Button className={interactiveText} onClick={() => onLogout()}>
39
+ Log out
40
+ </Button>
41
+ </MiddotSeparated>
42
+ </>
43
+ ) : (
44
+ <>
45
+ <LetterheadParagraph size="small">
46
+ Enable backup & sync, access your pledge data, and more!
47
+ </LetterheadParagraph>
48
+
49
+ <Link href={hrefs.join()} className={button()}>
50
+ Join
51
+ </Link>
52
+
53
+ <LetterheadParagraph size="small">
54
+ {"Already have an account? "}
55
+ <Link href={hrefs.login()} className={interactiveText}>
56
+ Log in
57
+ </Link>
58
+ {"."}
59
+ </LetterheadParagraph>
60
+ </>
61
+ )}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,49 @@
1
+ import { style } from "@vanilla-extract/css";
2
+ import { recipe } from "@vanilla-extract/recipes";
3
+
4
+ export const card = {
5
+ container: recipe({
6
+ base: {
7
+ display: "flex",
8
+ flexDirection: "column",
9
+ gap: "0.75rem",
10
+ backgroundColor: "white",
11
+ padding: "1.5rem",
12
+ borderRadius: "0.5rem",
13
+ color: "black",
14
+ },
15
+
16
+ variants: {
17
+ align: {
18
+ center: {
19
+ textAlign: "center",
20
+ },
21
+ start: {
22
+ textAlign: "start",
23
+ },
24
+ },
25
+ },
26
+ }),
27
+
28
+ logo: recipe({
29
+ base: {
30
+ maxInlineSize: "11rem",
31
+ },
32
+
33
+ variants: {
34
+ align: {
35
+ center: {
36
+ marginInline: "auto",
37
+ },
38
+ start: {
39
+ marginInline: "0",
40
+ },
41
+ },
42
+ },
43
+ }),
44
+
45
+ loggedInAction: style({
46
+ borderBlockStart: "1px solid hsl(0 0% 0% / 0.1)",
47
+ paddingBlockStart: "0.75rem",
48
+ }),
49
+ };
@@ -15,8 +15,8 @@ function DialogGuard(props: { children: ReactNode }) {
15
15
  }
16
16
 
17
17
  /**
18
- * Wraps AriaKit's DialogProvider, but take a tuple of Dialog a DialogDisclosure
19
- * elements as children, and makes sense that the Dialog component is not
18
+ * Wraps AriaKit's DialogProvider, but takes a tuple of Dialog a DialogDisclosure
19
+ * elements as children, and makes sure that the Dialog component is not
20
20
  * rendered when it is hidden.
21
21
  *
22
22
  * This is important in cases where the dialog contains a form that should only
@@ -0,0 +1,10 @@
1
+ import { useAppConfig } from "../AppConfig/AppConfig.tsx";
2
+
3
+ export function DocumentTitle(props: { children: string }) {
4
+ const { children: children } = props;
5
+ const { appName, isDev } = useAppConfig();
6
+ const itc = `${appName} · Indie Tabletop Club`;
7
+ const title = children ? `${children} | ${itc}` : itc;
8
+
9
+ return <title>{isDev ? `[DEV] ${title}` : title}</title>;
10
+ }
@@ -17,6 +17,7 @@ export function LoadingIndicator(props: { className?: string }) {
17
17
  width={width}
18
18
  height={height}
19
19
  className={props.className}
20
+ preserveAspectRatio="xMidYMid meet"
20
21
  >
21
22
  <g stroke="none" fill="inherit">
22
23
  {Array.from({ length: 3 }, (_, index) => {
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { MiddotSeparated } from "./MiddotSeparated.tsx";
3
+
4
+ type ComponentType = typeof MiddotSeparated;
5
+
6
+ type Story = StoryObj<typeof meta>;
7
+
8
+ const meta = {
9
+ title: "Components/Middot Separated",
10
+ component: MiddotSeparated,
11
+ tags: ["autodocs"],
12
+ } satisfies Meta<ComponentType>;
13
+
14
+ export default meta;
15
+
16
+ /**
17
+ * The default case in which all steps of the flow succeed.
18
+ */
19
+ export const Default: Story = {
20
+ args: {
21
+ children: ["Lorem", "Ipsum", " Dolor"],
22
+ },
23
+ };
24
+
25
+ /**
26
+ * Edge case when it comes to handling children in React.
27
+ */
28
+ export const SingleElement: Story = {
29
+ args: {
30
+ children: "Lorem",
31
+ },
32
+ };
@@ -0,0 +1,24 @@
1
+ import { type HTMLAttributes, Children } from "react";
2
+ import { withMiddlot } from "./style.css.ts";
3
+
4
+ type MiddotSeparatedProps = HTMLAttributes<HTMLDivElement>;
5
+
6
+ /**
7
+ * A utility component that wraps children into spans and adds middledots
8
+ * between each item using CSS ::before pseudo elements.
9
+ */
10
+ export function MiddotSeparated(props: MiddotSeparatedProps) {
11
+ const { children, ...divProps } = props;
12
+
13
+ return (
14
+ <div {...divProps}>
15
+ {Children.toArray(children).map((item, index) => {
16
+ return (
17
+ <span className={withMiddlot} key={index}>
18
+ {item}
19
+ </span>
20
+ );
21
+ })}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,10 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const withMiddlot = style({
4
+ selectors: {
5
+ "& + &::before": {
6
+ content: " · ",
7
+ opacity: 0.6,
8
+ },
9
+ },
10
+ });
@@ -114,13 +114,18 @@ export function createDatabaseBindings<T extends AnyModernIDB>(db: T) {
114
114
  return useContext(DatabaseOpenRequest);
115
115
  }
116
116
 
117
- function DatabaseProvider(props: { children: ReactNode }) {
117
+ function DatabaseProvider(props: { children: ReactNode; open?: boolean }) {
118
+ const { children, open = true } = props;
118
119
  const { op, setSuccess, setFailure } = useAsyncOp<
119
120
  T,
120
121
  InaccessibleDatabaseError
121
122
  >();
122
123
 
123
124
  useEffect(() => {
125
+ if (!open) {
126
+ return;
127
+ }
128
+
124
129
  db.open({
125
130
  onBlocking() {
126
131
  setFailure({ type: "CLOSED_FOR_UPGRADE" });
@@ -142,11 +147,11 @@ export function createDatabaseBindings<T extends AnyModernIDB>(db: T) {
142
147
  db.close();
143
148
  console.info("Database closed.");
144
149
  };
145
- }, [setFailure, setSuccess]);
150
+ }, [setFailure, setSuccess, open]);
146
151
 
147
152
  return (
148
153
  <DatabaseOpenRequest.Provider value={op}>
149
- {props.children}
154
+ {children}
150
155
  </DatabaseOpenRequest.Provider>
151
156
  );
152
157
  }
@@ -163,20 +163,6 @@ function createMocks(options?: { responseSpeed?: number }) {
163
163
 
164
164
  const { data, handlers } = createMocks({ responseSpeed: 700 });
165
165
 
166
- /**
167
- * Fetches fresh current user data if local data is provided.
168
- *
169
- * This component uses the Indie Tabletop Client under the hood, so if new
170
- * data is successfully fetched, the onCurrentUser callback will be invoked,
171
- * and it is up to the configuration of the client to store the data.
172
- *
173
- * Importantly, this component also handles the various user account issues
174
- * that we could run into: expired session, user mismatch and account deletion.
175
- *
176
- * All other errors are ignored. This allows users to use the app in offline
177
- * more, and doesn't interrupt their session if some unexpected error happens,
178
- * which they cannot do anything about anyways.
179
- */
180
166
  const meta = {
181
167
  title: "Account/Current User Fetcher",
182
168
  component: CurrentUserFetcher,
@@ -8,7 +8,7 @@ import { useCurrentUserResult } from "./useCurrentUserResult.tsx";
8
8
  import { UserMismatchView } from "./UserMismatchView.tsx";
9
9
  import { VerifyAccountView } from "./VerifyPage.tsx";
10
10
 
11
- export function CurrentUserFetcher(props: {
11
+ type CurrentUserFetcherProps = {
12
12
  /**
13
13
  * Current user as stored in persistent storage.
14
14
  *
@@ -16,14 +16,28 @@ export function CurrentUserFetcher(props: {
16
16
  * from the server and store it (via ITC client).
17
17
  */
18
18
  localUser: CurrentUser | null;
19
-
20
19
  onLogin: EventHandlerWithReload;
21
20
  onClearLocalContent: EventHandler;
22
21
  onLogout: EventHandlerWithReload;
23
22
  onServerLogout: EventHandlerWithReload;
24
-
25
23
  children: ReactNode;
26
- }) {
24
+ };
25
+
26
+ /**
27
+ * Fetches fresh current user data if local data is provided.
28
+ *
29
+ * This component uses the Indie Tabletop Client under the hood, so if new
30
+ * data is successfully fetched, the onCurrentUser callback will be invoked,
31
+ * and it is up to the configuration of the client to store the data.
32
+ *
33
+ * Importantly, this component also handles the various user account issues
34
+ * that we could run into: expired session, user mismatch and account deletion.
35
+ *
36
+ * All other errors are ignored. This allows users to use the app in offline
37
+ * more, and doesn't interrupt their session if some unexpected error happens,
38
+ * which they cannot do anything about anyways.
39
+ */
40
+ export function CurrentUserFetcher(props: CurrentUserFetcherProps) {
27
41
  const {
28
42
  localUser,
29
43
  children,
@@ -105,6 +119,8 @@ export function CurrentUserFetcher(props: {
105
119
  <VerifyAccountView
106
120
  currentUser={serverUser}
107
121
  onClose={() => setOpen(false)}
122
+ onLogout={onLogout}
123
+ reload={reload}
108
124
  />
109
125
  </ModalDialog>
110
126
 
@@ -4,7 +4,7 @@ import { http, HttpResponse } from "msw";
4
4
  import { fn } from "storybook/test";
5
5
  import { sleep } from "../sleep.ts";
6
6
  import type { CurrentUser, SessionInfo } from "../types.ts";
7
- import { JoinCard } from "./JoinPage.tsx";
7
+ import { JoinCard } from "./JoinCard.tsx";
8
8
 
9
9
  function createMocks(options?: { responseSpeed?: number }) {
10
10
  const simulateNetwork = () => sleep(options?.responseSpeed ?? 2000);
@@ -136,14 +136,12 @@ function createMocks(options?: { responseSpeed?: number }) {
136
136
 
137
137
  const { data, handlers } = createMocks({ responseSpeed: 700 });
138
138
 
139
- /**
140
- * Allows the user to reset their password.
141
- */
142
139
  const meta = {
143
- title: "Account/Join Page",
140
+ title: "Account/Join Card",
144
141
  component: JoinCard,
145
142
  tags: ["autodocs"],
146
143
  args: {
144
+ defaultValues: {},
147
145
  onLogout: fn(),
148
146
  },
149
147
  parameters: {
@@ -165,11 +163,7 @@ type Story = StoryObj<typeof meta>;
165
163
  /**
166
164
  * The default case in which all steps of the flow succeed.
167
165
  */
168
- export const Success: Story = {
169
- play: async ({ canvas, userEvent }) => {
170
- userEvent.type(canvas.getByTestId("email-input"), "foo@bar.com");
171
- },
172
- };
166
+ export const Success: Story = {};
173
167
 
174
168
  /**
175
169
  * In this case, the initial step fails because the email address is associated
@@ -87,7 +87,6 @@ function InitialStep(props: {
87
87
  type="email"
88
88
  placeholder={placeholders.email}
89
89
  autoComplete="username"
90
- data-testid="email-input"
91
90
  required
92
91
  />
93
92
 
@@ -98,7 +97,6 @@ function InitialStep(props: {
98
97
  placeholder="Choose a strong password"
99
98
  hint="Must be at least 8 characters"
100
99
  autoComplete="new-password"
101
- data-testid="new-password-input"
102
100
  required
103
101
  />
104
102
 
@@ -122,7 +120,7 @@ function InitialStep(props: {
122
120
  <a
123
121
  target="_blank"
124
122
  rel="noreferrer noopener"
125
- href={hrefs.terms()}
123
+ href={hrefs.terms({ content: "join" })}
126
124
  className={interactiveText}
127
125
  >
128
126
  Terms of Service
@@ -249,10 +247,15 @@ function JoinFlow(props: { defaultValues?: DefaultFormValues }) {
249
247
  }
250
248
  }
251
249
 
252
- export function JoinCard(props: {
250
+ export type JoinCardProps = {
253
251
  onLogout: EventHandlerWithReload;
254
252
  defaultValues?: DefaultFormValues;
255
- }) {
253
+ };
254
+
255
+ /**
256
+ * Allows the user to join Indie Tabletop Club.
257
+ */
258
+ export function JoinCard(props: JoinCardProps) {
256
259
  const { result, latestAttemptTs, reload } = useCurrentUserResult();
257
260
 
258
261
  return result.unpack(
@@ -4,7 +4,7 @@ import { http, HttpResponse } from "msw";
4
4
  import { fn } from "storybook/test";
5
5
  import { sleep } from "../sleep.ts";
6
6
  import type { CurrentUser, SessionInfo } from "../types.ts";
7
- import { LoginCard } from "./LoginPage.tsx";
7
+ import { LoginCard } from "./LoginCard.tsx";
8
8
 
9
9
  function createMocks(options?: { responseSpeed?: number }) {
10
10
  const simulateNetwork = () => sleep(options?.responseSpeed ?? 2000);
@@ -123,16 +123,14 @@ function createMocks(options?: { responseSpeed?: number }) {
123
123
 
124
124
  const { data, handlers } = createMocks({ responseSpeed: 700 });
125
125
 
126
- /**
127
- * Allows the user to log into Indie Tabletop Club.
128
- */
129
126
  const meta = {
130
- title: "Account/Login Page",
127
+ title: "Account/Login Card",
131
128
  component: LoginCard,
132
129
  tags: ["autodocs"],
133
130
  args: {
134
131
  currentUser: null,
135
132
  description: "Log in to Indie Tabletop Club to enable backup & sync.",
133
+ defaultValues: {},
136
134
  onLogin: fn(),
137
135
  onLogout: fn(),
138
136
  onClearLocalContent: fn(),
@@ -14,7 +14,7 @@ import type {
14
14
  import { useCurrentUserResult } from "./useCurrentUserResult.tsx";
15
15
  import { UserMismatchView } from "./UserMismatchView.tsx";
16
16
 
17
- export type LoginPageProps = {
17
+ export type LoginCardProps = {
18
18
  /**
19
19
  * Any user data that might currently be stored in persistent storage like
20
20
  * `localStorage` or IndexedDB.
@@ -82,7 +82,10 @@ export type LoginPageProps = {
82
82
  onServerLogout: EventHandlerWithReload;
83
83
  };
84
84
 
85
- export function LoginCard(props: LoginPageProps) {
85
+ /**
86
+ * Allows the user to log into Indie Tabletop Club.
87
+ */
88
+ export function LoginCard(props: LoginCardProps) {
86
89
  const { currentUser } = props;
87
90
  const { result, latestAttemptTs, reload } = useCurrentUserResult();
88
91
 
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { http, HttpResponse } from "msw";
4
4
  import { sleep } from "../sleep.ts";
5
5
  import type { CurrentUser, SessionInfo } from "../types.ts";
6
- import { PasswordResetCard } from "./PasswordResetPage.tsx";
6
+ import { PasswordResetCard } from "./PasswordResetCard.tsx";
7
7
 
8
8
  function createMocks(options?: { responseSpeed?: number }) {
9
9
  const simulateNetwork = () => sleep(options?.responseSpeed ?? 2000);
@@ -132,9 +132,6 @@ function createMocks(options?: { responseSpeed?: number }) {
132
132
 
133
133
  const { data, handlers } = createMocks({ responseSpeed: 700 });
134
134
 
135
- /**
136
- * Allows the user to reset their password.
137
- */
138
135
  const meta = {
139
136
  title: "Account/Password Reset Page",
140
137
  component: PasswordResetCard,
@@ -257,7 +257,7 @@ type ResetPasswordStep =
257
257
  | { type: "SET_NEW_PASSWORD"; tokenId: string; code: string; email: string }
258
258
  | { type: "SUCCESS" };
259
259
 
260
- export function PasswordResetCard(props: {
260
+ export type PasswordResetCardProps = {
261
261
  /**
262
262
  * Default values for the initial request password reset step.
263
263
  *
@@ -266,7 +266,12 @@ export function PasswordResetCard(props: {
266
266
  * is maintained between the two locations.
267
267
  */
268
268
  defaultValues?: DefaultFormValues;
269
- }) {
269
+ };
270
+
271
+ /**
272
+ * Allows the user to reset their password.
273
+ */
274
+ export function PasswordResetCard(props: PasswordResetCardProps) {
270
275
  const [step, setStep] = useState<ResetPasswordStep>({
271
276
  type: "REQUEST_PASSWORD_RESET",
272
277
  });
@@ -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
  }
@@ -50,6 +50,10 @@ globalStyle("body, h1, h2, h3, h4, h5, h6, p, ul, li, ol", {
50
50
  padding: 0,
51
51
  });
52
52
 
53
+ globalStyle("ul, ol", {
54
+ listStyle: "none",
55
+ });
56
+
53
57
  // Fathom SPA support depends on this image being added to the DOM, but they
54
58
  // are sloppy about taking out of the document flow, meaning that on pages
55
59
  // that are 100vh, there is a scrollbar flicker as the img element is added
package/lib/hrefs.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { LinkUtmParams, createUtm } from "./utm.ts";
2
+
3
+ type InputAppHrefs = {
4
+ login: () => string;
5
+ password: () => string;
6
+ join: () => string;
7
+ dashboard: () => string;
8
+ account: () => string;
9
+
10
+ // These are usually external links to the root domain, so we want to be
11
+ // able to set some tracking params.
12
+ terms?: (linkUtm?: LinkUtmParams) => string;
13
+ privacy?: (linkUtm?: LinkUtmParams) => string;
14
+ cookies?: (linkUtm?: LinkUtmParams) => string;
15
+ };
16
+
17
+ export type AppHrefs = Required<InputAppHrefs>;
18
+
19
+ export function createHrefs<T extends InputAppHrefs>(params: {
20
+ /**
21
+ * Hrefs to be used for the given app. At minimum, you need to provide
22
+ * the core hrefs required by Appkit.
23
+ */
24
+ hrefs: T;
25
+
26
+ /**
27
+ * The function responsible for generating UTM tags. You should
28
+ * use the return value of {@link createUtm} unless you are doing something
29
+ * unusual.
30
+ */
31
+ utm: ReturnType<typeof createUtm>;
32
+ }) {
33
+ const { utm, hrefs } = params;
34
+
35
+ return {
36
+ terms: (linkUtm?: LinkUtmParams) =>
37
+ `https://indietabletop.club/terms?${utm(linkUtm)}`,
38
+ privacy: (linkUtm?: LinkUtmParams) =>
39
+ `https://indietabletop.club/privacy?${utm(linkUtm)}`,
40
+ cookies: (linkUtm?: LinkUtmParams) =>
41
+ `https://indietabletop.club/cookies?${utm(linkUtm)}`,
42
+ itc: (linkUtm?: LinkUtmParams) =>
43
+ `https://indietabletop.club?${utm(linkUtm)}`,
44
+ fathom: () => `https://usefathom.com`,
45
+
46
+ ...hrefs,
47
+ };
48
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Given an doc id like `2025-01-01-some-name`, will return a date matching
3
+ * the starting portion of the id.
4
+ */
5
+ export function idToDate(id: string, fallback: () => Date) {
6
+ const dateString = /^(?<date>\d{4}-\d{2}-\d{2})/.exec(id)?.groups?.date;
7
+ return dateString ? new Date(dateString) : fallback();
8
+ }
package/lib/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // Components
2
2
  export * from "./account/CurrentUserFetcher.tsx";
3
- export * from "./account/JoinPage.tsx";
4
- export * from "./account/LoginPage.tsx";
5
- export * from "./account/PasswordResetPage.tsx";
3
+ export * from "./account/JoinCard.tsx";
4
+ export * from "./account/LoginCard.tsx";
5
+ export * from "./account/PasswordResetCard.tsx";
6
6
  export * from "./AppConfig/AppConfig.tsx";
7
+ export * from "./AuthCard/AuthCard.tsx";
7
8
  export * from "./DialogTrigger/index.tsx";
9
+ export * from "./DocumentTitle/DocumentTitle.tsx";
8
10
  export * from "./ExternalLink.tsx";
9
11
  export * from "./FormSubmitButton.tsx";
10
12
  export * from "./FullscreenDismissBlocker.tsx";
@@ -13,6 +15,7 @@ export * from "./IndieTabletopClubSymbol.tsx";
13
15
  export * from "./Letterhead/index.tsx";
14
16
  export * from "./LetterheadForm/index.tsx";
15
17
  export * from "./LoadingIndicator.tsx";
18
+ export * from "./MiddotSeparated/MiddotSeparated.tsx";
16
19
  export * from "./ModalDialog/index.tsx";
17
20
  export * from "./QRCode/QRCode.tsx";
18
21
  export * from "./ReleaseInfo/index.tsx";
@@ -43,14 +46,19 @@ export * from "./copyrightRange.ts";
43
46
  export * from "./failureMessages.ts";
44
47
  export * from "./groupBy.ts";
45
48
  export * from "./HistoryState.ts";
49
+ export * from "./hrefs.ts";
46
50
  export * from "./ids.ts";
51
+ export * from "./idToDate.ts";
52
+ export * from "./mailto.ts";
47
53
  export * from "./media.ts";
48
54
  export * from "./random.ts";
55
+ export * from "./result/swr.ts";
49
56
  export * from "./sleep.ts";
50
57
  export * from "./structs.ts";
51
58
  export * from "./typeguards.ts";
52
59
  export * from "./types.ts";
53
60
  export * from "./unique.ts";
61
+ export * from "./utm.ts";
54
62
  export * from "./validations.ts";
55
63
 
56
64
  // Other
package/lib/mailto.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Encodes values to be used in mailto protocol.
3
+ *
4
+ * Note that we cannot simply use URLSeachParams because they, for example, encode a space
5
+ * as plus (+), which cannot be used in mailto if we want to have consistent behaviour in
6
+ * all email clients.
7
+ */
8
+ function encodeValuesForMailto<T extends object>(object: T) {
9
+ return Object.entries(object)
10
+ .filter(([_, v]) => !!v)
11
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
12
+ .join("&");
13
+ }
14
+
15
+ function serializeArray(value?: string | string[] | null) {
16
+ return Array.isArray(value) ? value.join(",") : value;
17
+ }
18
+
19
+ type MailtoPayload = {
20
+ body?: string | null;
21
+ subject?: string | null;
22
+ cc?: string | string[] | null;
23
+ bcc?: string | string[] | null;
24
+ };
25
+
26
+ export function mailto(recipient: string | null, payload?: MailtoPayload) {
27
+ // If the recipient is falsy the user can choose who to send the email to.
28
+ const to = recipient ?? "";
29
+
30
+ const serialized = payload
31
+ ? `?${encodeValuesForMailto({
32
+ body: payload.body,
33
+ subject: payload.subject,
34
+ cc: serializeArray(payload.cc),
35
+ bcc: serializeArray(payload.bcc),
36
+ })}`
37
+ : "";
38
+
39
+ return `mailto:${to}${serialized}`;
40
+ }
package/lib/types.ts CHANGED
@@ -1,6 +1,40 @@
1
1
  import type { Infer } from "superstruct";
2
2
  import { currentUser, redeemedPledge, sessionInfo } from "./structs.js";
3
3
 
4
+ // Generic type helpers
5
+
6
+ type Brand<B> = { __brand: B };
7
+
8
+ export type Branded<T, B> = T & Brand<B>;
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
+
27
+ /**
28
+ * A branded string.
29
+ *
30
+ * Use this type to make a HTML string as trusted. This can be either HTML
31
+ * coming from a source that we know is safe (statically generated markdown
32
+ * that exists in our codebase) or sanitized user-generated content.
33
+ */
34
+ export type TrustedHtml = Branded<string, "TrustedHtml">;
35
+
36
+ // Common ITC types
37
+
4
38
  export type CurrentUser = Infer<ReturnType<typeof currentUser>>;
5
39
 
6
40
  export type SessionInfo = Infer<ReturnType<typeof sessionInfo>>;
package/lib/utm.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Given an object with keys that might contain undefined values, returns a new
3
+ * object only with keys that are not undefined.
4
+ */
5
+ function omitUndefinedKeys<T>(record: Record<string, T | undefined>) {
6
+ return Object.fromEntries(
7
+ Object.entries(record).filter(([_, v]) => v !== undefined),
8
+ ) as Record<string, T>;
9
+ }
10
+
11
+ /**
12
+ * Full UTM configuration object.
13
+ */
14
+ export type UtmParams = {
15
+ /**
16
+ * The website/platform the visitor is coming from.
17
+ */
18
+ source: string;
19
+
20
+ /**
21
+ * The type of marketing channel (e.g. cpc, email, affiliate, social,
22
+ * or similar).
23
+ */
24
+ medium: string;
25
+
26
+ /**
27
+ * The name of the campaign or product description. In our case, this is
28
+ * usually the name of the app.
29
+ */
30
+ campaign: string;
31
+
32
+ /**
33
+ * Optionally, provide an identifier for the place from which the link was
34
+ * clicked. E.g. footer, join, etc.
35
+ */
36
+ content?: string;
37
+
38
+ /**
39
+ * Optionally, identify paid keywords. We usually do not use this.
40
+ */
41
+ term?: string;
42
+ };
43
+
44
+ /**
45
+ * UTM Params configuration that is appropriate to set at the link level,
46
+ * if app-level defaults have been set.
47
+ */
48
+ export type LinkUtmParams = Pick<UtmParams, "content" | "term">;
49
+
50
+ /**
51
+ * Returns URL Search params with provided UTM configuration.
52
+ *
53
+ * Most of the time, you probably want to set up some defaults using
54
+ * {@link createUtm}. This function is intended for special cases.
55
+ */
56
+ export function utm(params: UtmParams) {
57
+ return new URLSearchParams(
58
+ omitUndefinedKeys({
59
+ utm_source: params.source,
60
+ utm_medium: params.medium,
61
+ utm_campaign: params.campaign,
62
+ utm_content: params.content,
63
+ utm_term: params.term,
64
+ }),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * A factory for the {@link utm} function. Use it to set sensible defaults for
70
+ * further use of the returned function.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const utm = createUtm({
75
+ * source: "indietabletopclub",
76
+ * medium: "referral",
77
+ * campaign: "spacegitsapp",
78
+ * });
79
+ *
80
+ * utm() // => URLSearchParams that inlude the above config
81
+ *
82
+ * utm({ campaign: "foo", content: "bar" }) // Sets optional fiels and overrides
83
+ * ```
84
+ */
85
+ export function createUtm(defaults: UtmParams) {
86
+ return function (params?: Partial<UtmParams>) {
87
+ return utm({
88
+ ...defaults,
89
+ ...(params && omitUndefinedKeys(params)),
90
+ });
91
+ };
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "3.6.0-3",
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,9 +0,0 @@
1
- import { useAppConfig } from "../AppConfig/AppConfig.tsx";
2
-
3
- export function Title(props: { children: string }) {
4
- const { appName } = useAppConfig();
5
-
6
- return (
7
- <title>{`${props.children} | ${appName} · Indie Tabletop Club`}</title>
8
- );
9
- }
@@ -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,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
- });