@indietabletop/appkit 3.6.0-3 → 4.0.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/AppConfig/AppConfig.tsx +2 -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 +9 -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 +18 -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/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.test.ts +66 -0
- package/lib/mailto.ts +40 -0
- package/lib/types.ts +17 -0
- package/lib/utm.ts +89 -0
- package/package.json +1 -1
- package/lib/Title/index.tsx +0 -9
|
@@ -1,17 +1,11 @@
|
|
|
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;
|
|
6
7
|
client: IndieTabletopClient;
|
|
7
|
-
hrefs:
|
|
8
|
-
login: () => string;
|
|
9
|
-
password: () => string;
|
|
10
|
-
join: () => string;
|
|
11
|
-
terms: () => string;
|
|
12
|
-
verify: () => string;
|
|
13
|
-
dashboard: () => string;
|
|
14
|
-
};
|
|
8
|
+
hrefs: AppHrefs;
|
|
15
9
|
placeholders: {
|
|
16
10
|
email: string;
|
|
17
11
|
};
|
|
@@ -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,9 @@
|
|
|
1
|
+
import { useAppConfig } from "../AppConfig/AppConfig.tsx";
|
|
2
|
+
|
|
3
|
+
export function DocumentTitle(props: { children: string }) {
|
|
4
|
+
const { children: title } = props;
|
|
5
|
+
const { appName } = useAppConfig();
|
|
6
|
+
const itc = `${appName} · Indie Tabletop Club`;
|
|
7
|
+
|
|
8
|
+
return <title>{title ? `${title} | ${itc}` : itc}</title>;
|
|
9
|
+
}
|
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,
|
|
@@ -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
|
});
|
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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { mailto as emailTemplateToMailto } from "./mailto.ts";
|
|
3
|
+
|
|
4
|
+
describe("emailTempalteToMailto", () => {
|
|
5
|
+
test("correctly serializes data", () => {
|
|
6
|
+
expect(
|
|
7
|
+
emailTemplateToMailto(null, {
|
|
8
|
+
body: `Hello world!\n\nI am URL escaped.`,
|
|
9
|
+
subject: `This is a subject?`,
|
|
10
|
+
cc: null,
|
|
11
|
+
}),
|
|
12
|
+
).toMatchInlineSnapshot(
|
|
13
|
+
`"mailto:?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F"`,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("includes recipient when provided", () => {
|
|
18
|
+
expect(
|
|
19
|
+
emailTemplateToMailto("hi@example.com", {
|
|
20
|
+
body: `Hello world!\n\nI am URL escaped.`,
|
|
21
|
+
subject: `This is a subject?`,
|
|
22
|
+
cc: null,
|
|
23
|
+
}),
|
|
24
|
+
).toMatchInlineSnapshot(
|
|
25
|
+
`"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F"`,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("includes cc when provided", () => {
|
|
30
|
+
expect(
|
|
31
|
+
emailTemplateToMailto("hi@example.com", {
|
|
32
|
+
body: `Hello world!\n\nI am URL escaped.`,
|
|
33
|
+
subject: `This is a subject?`,
|
|
34
|
+
cc: "cc@example.com",
|
|
35
|
+
}),
|
|
36
|
+
).toMatchInlineSnapshot(
|
|
37
|
+
`"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F&cc=cc%40example.com"`,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("includes bcc when provided", () => {
|
|
42
|
+
expect(
|
|
43
|
+
emailTemplateToMailto("hi@example.com", {
|
|
44
|
+
body: `Hello world!\n\nI am URL escaped.`,
|
|
45
|
+
subject: `This is a subject?`,
|
|
46
|
+
cc: "cc@example.com",
|
|
47
|
+
bcc: "bcc@example.com",
|
|
48
|
+
}),
|
|
49
|
+
).toMatchInlineSnapshot(
|
|
50
|
+
`"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F&cc=cc%40example.com&bcc=bcc%40example.com"`,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("cc and bcc allow array values", () => {
|
|
55
|
+
expect(
|
|
56
|
+
emailTemplateToMailto("hi@example.com", {
|
|
57
|
+
body: `Hello world!\n\nI am URL escaped.`,
|
|
58
|
+
subject: `This is a subject?`,
|
|
59
|
+
cc: ["cc@example.com", "cc2@example.com"],
|
|
60
|
+
bcc: ["bcc@example.com", "bcc2@example.com"],
|
|
61
|
+
}),
|
|
62
|
+
).toMatchInlineSnapshot(
|
|
63
|
+
`"mailto:hi@example.com?body=Hello%20world!%0A%0AI%20am%20URL%20escaped.&subject=This%20is%20a%20subject%3F&cc=cc%40example.com%2Ccc2%40example.com&bcc=bcc%40example.com%2Cbcc2%40example.com"`,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
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,23 @@
|
|
|
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
|
+
* A branded string.
|
|
12
|
+
*
|
|
13
|
+
* Use this type to make a HTML string as trusted. This can be either HTML
|
|
14
|
+
* coming from a source that we know is safe (statically generated markdown
|
|
15
|
+
* that exists in our codebase) or sanitized user-generated content.
|
|
16
|
+
*/
|
|
17
|
+
export type TrustedHtml = Branded<string, "TrustedHtml">;
|
|
18
|
+
|
|
19
|
+
// Common ITC types
|
|
20
|
+
|
|
4
21
|
export type CurrentUser = Infer<ReturnType<typeof currentUser>>;
|
|
5
22
|
|
|
6
23
|
export type SessionInfo = Infer<ReturnType<typeof sessionInfo>>;
|
package/lib/utm.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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({ ...defaults, ...params });
|
|
88
|
+
};
|
|
89
|
+
}
|
package/package.json
CHANGED
package/lib/Title/index.tsx
DELETED