@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.
- package/lib/AppConfig/AppConfig.tsx +3 -8
- package/lib/AuthCard/AuthCard.stories.ts +38 -0
- package/lib/AuthCard/AuthCard.tsx +64 -0
- package/lib/AuthCard/style.css.ts +49 -0
- package/lib/DialogTrigger/index.tsx +2 -2
- package/lib/DocumentTitle/DocumentTitle.tsx +10 -0
- package/lib/LoadingIndicator.tsx +1 -0
- package/lib/MiddotSeparated/MiddotSeparated.stories.ts +32 -0
- package/lib/MiddotSeparated/MiddotSeparated.tsx +24 -0
- package/lib/MiddotSeparated/style.css.ts +10 -0
- package/lib/ModernIDB/bindings/factory.tsx +8 -3
- package/lib/account/CurrentUserFetcher.stories.tsx +0 -14
- package/lib/account/CurrentUserFetcher.tsx +20 -4
- package/lib/account/{JoinPage.stories.tsx → JoinCard.stories.tsx} +4 -10
- package/lib/account/{JoinPage.tsx → JoinCard.tsx} +8 -5
- package/lib/account/{LoginPage.stories.tsx → LoginCard.stories.tsx} +3 -5
- package/lib/account/{LoginPage.tsx → LoginCard.tsx} +5 -2
- package/lib/account/{PasswordResetPage.stories.tsx → PasswordResetCard.stories.tsx} +1 -4
- package/lib/account/{PasswordResetPage.tsx → PasswordResetCard.tsx} +7 -2
- package/lib/account/VerifyPage.tsx +29 -54
- package/lib/account/useCurrentUserResult.tsx +16 -1
- package/lib/copyrightRange.ts +7 -3
- package/lib/globals.css.ts +4 -0
- package/lib/hrefs.ts +48 -0
- package/lib/idToDate.ts +8 -0
- package/lib/index.ts +11 -3
- package/lib/mailto.ts +40 -0
- package/lib/types.ts +34 -0
- package/lib/utm.ts +92 -0
- package/package.json +6 -6
- package/lib/Title/index.tsx +0 -9
- package/lib/append-copy-to-text.test.ts +0 -29
- package/lib/failureMessages.test.ts +0 -169
- 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
|
|
19
|
-
* elements as children, and makes
|
|
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
|
+
}
|
package/lib/LoadingIndicator.tsx
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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 "./
|
|
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
|
|
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
|
|
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 "./
|
|
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
|
|
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
|
|
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
|
-
|
|
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 "./
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
package/lib/copyrightRange.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export function copyrightRange(yearSince: number) {
|
|
2
2
|
const currentYear = new Date().getFullYear();
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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/globals.css.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/idToDate.ts
ADDED
|
@@ -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/
|
|
4
|
-
export * from "./account/
|
|
5
|
-
export * from "./account/
|
|
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
|
+
"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.
|
|
29
|
-
"@storybook/addon-links": "^9.1.
|
|
30
|
-
"@storybook/react-vite": "^9.1.
|
|
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.
|
|
35
|
+
"storybook": "^9.1.10",
|
|
36
36
|
"typescript": "^5.8.2",
|
|
37
37
|
"vite": "^6.3.5",
|
|
38
|
-
"vitest": "^3.
|
|
38
|
+
"vitest": "^3.2.4"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@ariakit/react": "^0.4.17",
|
package/lib/Title/index.tsx
DELETED
|
@@ -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
|
-
});
|
package/lib/unique.test.ts
DELETED
|
@@ -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
|
-
});
|