@indietabletop/appkit 3.6.0-2 → 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.
Files changed (60) hide show
  1. package/lib/AppConfig/AppConfig.tsx +48 -0
  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 +9 -0
  7. package/lib/HistoryState.ts +21 -0
  8. package/lib/Letterhead/style.css.ts +2 -0
  9. package/lib/LetterheadForm/index.tsx +53 -0
  10. package/lib/LetterheadForm/style.css.ts +8 -0
  11. package/lib/LoadingIndicator.tsx +1 -0
  12. package/lib/MiddotSeparated/MiddotSeparated.stories.ts +32 -0
  13. package/lib/MiddotSeparated/MiddotSeparated.tsx +24 -0
  14. package/lib/MiddotSeparated/style.css.ts +10 -0
  15. package/lib/ModernIDB/bindings/factory.tsx +8 -3
  16. package/lib/QRCode/QRCode.stories.tsx +47 -0
  17. package/lib/QRCode/QRCode.tsx +49 -0
  18. package/lib/QRCode/style.css.ts +19 -0
  19. package/lib/ShareButton/ShareButton.tsx +141 -0
  20. package/lib/SubscribeCard/LetterheadInfoCard.tsx +23 -0
  21. package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +83 -0
  22. package/lib/SubscribeCard/SubscribeByEmailCard.tsx +177 -0
  23. package/lib/SubscribeCard/SubscribeCard.stories.tsx +27 -23
  24. package/lib/SubscribeCard/SubscribeCard.tsx +17 -16
  25. package/lib/account/AccountIssueView.tsx +40 -0
  26. package/lib/account/AlreadyLoggedInView.tsx +44 -0
  27. package/lib/account/CurrentUserFetcher.stories.tsx +325 -0
  28. package/lib/account/CurrentUserFetcher.tsx +133 -0
  29. package/lib/account/FailureFallbackView.tsx +36 -0
  30. package/lib/account/JoinCard.stories.tsx +264 -0
  31. package/lib/account/JoinCard.tsx +291 -0
  32. package/lib/account/LoadingView.tsx +14 -0
  33. package/lib/account/LoginCard.stories.tsx +316 -0
  34. package/lib/account/LoginCard.tsx +141 -0
  35. package/lib/account/LoginView.tsx +136 -0
  36. package/lib/account/NoConnectionView.tsx +34 -0
  37. package/lib/account/PasswordResetCard.stories.tsx +247 -0
  38. package/lib/account/PasswordResetCard.tsx +296 -0
  39. package/lib/account/UserMismatchView.tsx +61 -0
  40. package/lib/account/VerifyPage.tsx +217 -0
  41. package/lib/account/style.css.ts +57 -0
  42. package/lib/account/types.ts +9 -0
  43. package/lib/account/useCurrentUserResult.tsx +38 -0
  44. package/lib/class-names.ts +1 -1
  45. package/lib/client.ts +54 -7
  46. package/lib/globals.css.ts +9 -0
  47. package/lib/hrefs.ts +48 -0
  48. package/lib/idToDate.ts +8 -0
  49. package/lib/index.ts +19 -1
  50. package/lib/mailto.test.ts +66 -0
  51. package/lib/mailto.ts +40 -0
  52. package/lib/types.ts +17 -0
  53. package/lib/useEnsureValue.ts +31 -0
  54. package/lib/utm.ts +89 -0
  55. package/package.json +3 -2
  56. package/lib/ClientContext/ClientContext.tsx +0 -25
  57. package/lib/LoginPage/LoginPage.stories.tsx +0 -107
  58. package/lib/LoginPage/LoginPage.tsx +0 -204
  59. package/lib/LoginPage/style.css.ts +0 -17
  60. package/lib/Title/index.tsx +0 -4
@@ -0,0 +1,264 @@
1
+ import { Story } from "@storybook/addon-docs/blocks";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { http, HttpResponse } from "msw";
4
+ import { fn } from "storybook/test";
5
+ import { sleep } from "../sleep.ts";
6
+ import type { CurrentUser, SessionInfo } from "../types.ts";
7
+ import { JoinCard } from "./JoinCard.tsx";
8
+
9
+ function createMocks(options?: { responseSpeed?: number }) {
10
+ const simulateNetwork = () => sleep(options?.responseSpeed ?? 2000);
11
+
12
+ const john: CurrentUser = {
13
+ id: "john",
14
+ email: "john@example.com",
15
+ isVerified: true,
16
+ };
17
+
18
+ const sessionInfo: SessionInfo = {
19
+ createdTs: 123,
20
+ expiresTs: 123,
21
+ };
22
+
23
+ return {
24
+ data: { john },
25
+ handlers: {
26
+ refreshTokens: {
27
+ failed: () => {
28
+ return http.post(
29
+ "http://mock.api/v1/sessions/access-tokens",
30
+ async () => {
31
+ await simulateNetwork();
32
+ return HttpResponse.text("Refresh token expired or missing", {
33
+ status: 401,
34
+ });
35
+ },
36
+ );
37
+ },
38
+ },
39
+
40
+ getCurrentUser: {
41
+ success: (currentUser: CurrentUser) => {
42
+ return http.get("http://mock.api/v1/users/me", async () => {
43
+ await simulateNetwork();
44
+ return HttpResponse.json(currentUser);
45
+ });
46
+ },
47
+
48
+ noConnection: () => {
49
+ return http.get("http://mock.api/v1/users/me", async () => {
50
+ return HttpResponse.error();
51
+ });
52
+ },
53
+
54
+ unknownFailure: () => {
55
+ return http.get("http://mock.api/v1/users/me", async () => {
56
+ await simulateNetwork();
57
+ return HttpResponse.text("Internal server error", { status: 500 });
58
+ });
59
+ },
60
+
61
+ /**
62
+ * Auth cookies no longer valid to make this request.
63
+ */
64
+ notAuthenticated: () => {
65
+ return http.get("http://mock.api/v1/users/me", async () => {
66
+ await simulateNetwork();
67
+ return HttpResponse.text("Not authenticated", { status: 401 });
68
+ });
69
+ },
70
+ },
71
+
72
+ join: {
73
+ success(currentUser: CurrentUser) {
74
+ return http.post("http://mock.api/v1/users", async () => {
75
+ await simulateNetwork();
76
+
77
+ return HttpResponse.json({
78
+ currentUser,
79
+ sessionInfo,
80
+ tokenId: "1",
81
+ });
82
+ });
83
+ },
84
+
85
+ emailTaken() {
86
+ return http.post("http://mock.api/v1/users", async () => {
87
+ await simulateNetwork();
88
+
89
+ return HttpResponse.text("Duplicate unique key", { status: 409 });
90
+ });
91
+ },
92
+
93
+ unknownFailure() {
94
+ return http.post("http://mock.api/v1/users", async () => {
95
+ await simulateNetwork();
96
+ return HttpResponse.text("Internal server error", {
97
+ status: 500,
98
+ });
99
+ });
100
+ },
101
+ },
102
+ verify: {
103
+ success() {
104
+ return http.put(
105
+ "http://mock.api/v1/user-verification-tokens/:id",
106
+ async () => {
107
+ await simulateNetwork();
108
+ return HttpResponse.json({ message: "OK" });
109
+ },
110
+ );
111
+ },
112
+ notFoundOrExpired() {
113
+ return http.put(
114
+ "http://mock.api/v1/user-verification-tokens/:id",
115
+ async () => {
116
+ await simulateNetwork();
117
+ return HttpResponse.text("Not found", { status: 404 });
118
+ },
119
+ );
120
+ },
121
+ unknownFailure() {
122
+ return http.put(
123
+ "http://mock.api/v1/user-verification-tokens/:id",
124
+ async () => {
125
+ await simulateNetwork();
126
+ return HttpResponse.text("Internal server error", {
127
+ status: 500,
128
+ });
129
+ },
130
+ );
131
+ },
132
+ },
133
+ },
134
+ };
135
+ }
136
+
137
+ const { data, handlers } = createMocks({ responseSpeed: 700 });
138
+
139
+ const meta = {
140
+ title: "Account/Join Card",
141
+ component: JoinCard,
142
+ tags: ["autodocs"],
143
+ args: {
144
+ defaultValues: {},
145
+ onLogout: fn(),
146
+ },
147
+ parameters: {
148
+ msw: {
149
+ handlers: {
150
+ refresh: handlers.refreshTokens.failed(),
151
+ currentUser: handlers.getCurrentUser.notAuthenticated(),
152
+ join: handlers.join.success(data.john),
153
+ verify: handlers.verify.success(),
154
+ },
155
+ },
156
+ },
157
+ } satisfies Meta<typeof JoinCard>;
158
+
159
+ export default meta;
160
+
161
+ type Story = StoryObj<typeof meta>;
162
+
163
+ /**
164
+ * The default case in which all steps of the flow succeed.
165
+ */
166
+ export const Success: Story = {};
167
+
168
+ /**
169
+ * In this case, the initial step fails because the email address is associated
170
+ * with an existing user.
171
+ */
172
+ export const EmailTakenOnJoin: Story = {
173
+ parameters: {
174
+ msw: {
175
+ handlers: {
176
+ join: handlers.join.emailTaken(),
177
+ },
178
+ },
179
+ },
180
+ };
181
+
182
+ /**
183
+ * In this case, the initial step fails for a reason that doesn't have any
184
+ * special handling in this location. E.g. network error, server error, etc.
185
+ */
186
+ export const UnknownFailureOnJoin: Story = {
187
+ parameters: {
188
+ msw: {
189
+ handlers: {
190
+ join: handlers.join.unknownFailure(),
191
+ },
192
+ },
193
+ },
194
+ };
195
+
196
+ /**
197
+ * In this case, the verify step fails because the token has expired,
198
+ * or is incorrect.
199
+ */
200
+ export const NotFoundOrExpiredOnVerify: Story = {
201
+ parameters: {
202
+ msw: {
203
+ handlers: {
204
+ verify: handlers.verify.notFoundOrExpired(),
205
+ },
206
+ },
207
+ },
208
+ };
209
+
210
+ /**
211
+ * In this case, the verify step fails for a reason that doesn't have any
212
+ * special handling in this location. E.g. network error, server error, etc.
213
+ */
214
+ export const UnknownFailureOnVerify: Story = {
215
+ parameters: {
216
+ msw: {
217
+ handlers: {
218
+ verify: handlers.verify.unknownFailure(),
219
+ },
220
+ },
221
+ },
222
+ };
223
+
224
+ /**
225
+ * The proactive user-session check returns a user account.
226
+ *
227
+ * The user can continue to use the app, or log out.
228
+ */
229
+ export const AlreadyLoggedIn: Story = {
230
+ parameters: {
231
+ msw: {
232
+ handlers: {
233
+ currentUser: handlers.getCurrentUser.success(data.john),
234
+ },
235
+ },
236
+ },
237
+ };
238
+
239
+ /**
240
+ * The proactive user session check has failed due to connection issues.
241
+ */
242
+ export const NoConnection: Story = {
243
+ parameters: {
244
+ msw: {
245
+ handlers: {
246
+ currentUser: handlers.getCurrentUser.noConnection(),
247
+ },
248
+ },
249
+ },
250
+ };
251
+
252
+ /**
253
+ * The proactive user session check has failed due to an error that doesn't
254
+ * carry any special meaning.
255
+ */
256
+ export const UnknownFailure: Story = {
257
+ parameters: {
258
+ msw: {
259
+ handlers: {
260
+ currentUser: handlers.getCurrentUser.unknownFailure(),
261
+ },
262
+ },
263
+ },
264
+ };
@@ -0,0 +1,291 @@
1
+ import { Form, useStoreState } from "@ariakit/react";
2
+ import { type Dispatch, type SetStateAction, useState } from "react";
3
+ import { Link } from "wouter";
4
+ import { useAppConfig, useClient } from "../AppConfig/AppConfig.tsx";
5
+ import { cx } from "../class-names.ts";
6
+ import { interactiveText } from "../common.css.ts";
7
+ import { getSubmitFailureMessage } from "../failureMessages.ts";
8
+ import {
9
+ Letterhead,
10
+ LetterheadHeading,
11
+ LetterheadParagraph,
12
+ LetterheadSubmitButton,
13
+ } from "../Letterhead/index.tsx";
14
+ import { button } from "../Letterhead/style.css.ts";
15
+ import {
16
+ InputsStack,
17
+ LetterheadCheckboxField,
18
+ LetterheadFormActions,
19
+ LetterheadHeader,
20
+ LetterheadSubmitError,
21
+ LetterheadTextField,
22
+ } from "../LetterheadForm/index.tsx";
23
+ import { useForm } from "../use-form.ts";
24
+ import { validEmail, validPassword } from "../validations.ts";
25
+ import { AlreadyLoggedInView } from "./AlreadyLoggedInView.tsx";
26
+ import { FailureFallbackView } from "./FailureFallbackView.tsx";
27
+ import { LoadingView } from "./LoadingView.tsx";
28
+ import { NoConnectionView } from "./NoConnectionView.tsx";
29
+ import type { DefaultFormValues, EventHandlerWithReload } from "./types.ts";
30
+ import { useCurrentUserResult } from "./useCurrentUserResult.tsx";
31
+
32
+ type SetStep = Dispatch<SetStateAction<JoinStep>>;
33
+
34
+ function InitialStep(props: {
35
+ setStep: SetStep;
36
+ defaultValues?: DefaultFormValues;
37
+ }) {
38
+ const { client, placeholders, hrefs } = useAppConfig();
39
+ const { defaultValues, setStep } = props;
40
+
41
+ const { form, submitName } = useForm({
42
+ defaultValues: {
43
+ email: defaultValues?.email ?? "",
44
+ password: "",
45
+ acceptedTos: false,
46
+ subscribedToNewsletter: false,
47
+ },
48
+ validate: {
49
+ email: validEmail,
50
+ password: validPassword,
51
+ },
52
+ async onSubmit({ values }) {
53
+ const op = await client.join(values);
54
+
55
+ return op.mapFailure((failure) => {
56
+ return getSubmitFailureMessage(failure, {
57
+ 409: "🙀 This email address is already in use. Please choose a different one, or log in.",
58
+ });
59
+ });
60
+ },
61
+ onSuccess(response, form) {
62
+ setStep({
63
+ type: "SUBMIT_CODE",
64
+ tokenId: response.tokenId,
65
+ email: form.values.email,
66
+ });
67
+ },
68
+ });
69
+
70
+ const emailValue = useStoreState(form, (s) => s.values.email);
71
+
72
+ return (
73
+ <Letterhead>
74
+ <LetterheadHeader>
75
+ <LetterheadHeading>Join Indie Tabletop Club</LetterheadHeading>
76
+ <LetterheadParagraph>
77
+ Enter your email and choose a strong password. We will send you a
78
+ one-time code to verify your account.
79
+ </LetterheadParagraph>
80
+ </LetterheadHeader>
81
+
82
+ <Form store={form} resetOnSubmit={false}>
83
+ <InputsStack>
84
+ <LetterheadTextField
85
+ name={form.names.email}
86
+ label="Email"
87
+ type="email"
88
+ placeholder={placeholders.email}
89
+ autoComplete="username"
90
+ required
91
+ />
92
+
93
+ <LetterheadTextField
94
+ name={form.names.password}
95
+ label="Password"
96
+ type="password"
97
+ placeholder="Choose a strong password"
98
+ hint="Must be at least 8 characters"
99
+ autoComplete="new-password"
100
+ required
101
+ />
102
+
103
+ <LetterheadCheckboxField
104
+ name={form.names.subscribedToNewsletter}
105
+ label={
106
+ <>
107
+ Subscribe to <em>The Changelog</em>, an at-most-once-a-month
108
+ newsletter about updates and new releases. You can unsubscribe
109
+ any time with one click.
110
+ </>
111
+ }
112
+ />
113
+
114
+ <LetterheadCheckboxField
115
+ name={form.names.acceptedTos}
116
+ required
117
+ label={
118
+ <>
119
+ I accept the{" "}
120
+ <a
121
+ target="_blank"
122
+ rel="noreferrer noopener"
123
+ href={hrefs.terms({ content: "join" })}
124
+ className={interactiveText}
125
+ >
126
+ Terms of Service
127
+ </a>
128
+ .
129
+ </>
130
+ }
131
+ />
132
+ </InputsStack>
133
+
134
+ <LetterheadFormActions>
135
+ <LetterheadSubmitError name={submitName} />
136
+ <LetterheadSubmitButton>Join</LetterheadSubmitButton>
137
+
138
+ <LetterheadParagraph align="start">
139
+ {"Have an existing account? "}
140
+ <Link
141
+ {...cx(interactiveText)}
142
+ href={hrefs.login()}
143
+ state={{ emailValue }}
144
+ >
145
+ Log in
146
+ </Link>
147
+ {"."}
148
+ </LetterheadParagraph>
149
+ </LetterheadFormActions>
150
+ </Form>
151
+ </Letterhead>
152
+ );
153
+ }
154
+
155
+ function SubmitCodeStep(props: {
156
+ tokenId: string;
157
+ setStep: SetStep;
158
+ email: string;
159
+ }) {
160
+ const { tokenId, email, setStep } = props;
161
+ const client = useClient();
162
+
163
+ const { form, submitName } = useForm({
164
+ defaultValues: {
165
+ code: "",
166
+ },
167
+ async onSubmit({ values }) {
168
+ const op = await client.verifyUser({ ...values, tokenId });
169
+
170
+ return op.mapFailure((failure) => {
171
+ return getSubmitFailureMessage(failure, {
172
+ 404: "🚫 This code is incorrect or expired. Please try again.",
173
+ });
174
+ });
175
+ },
176
+ onSuccess() {
177
+ setStep({ type: "SUCCESS" });
178
+ },
179
+ });
180
+
181
+ return (
182
+ <Form store={form} resetOnSubmit={false}>
183
+ <Letterhead>
184
+ <LetterheadHeader>
185
+ <LetterheadHeading>Verify account</LetterheadHeading>
186
+ <LetterheadParagraph>
187
+ We've sent a one-time code to <em>{email}</em>. Please, enter the
188
+ code in the field below to verify your account.
189
+ </LetterheadParagraph>
190
+ </LetterheadHeader>
191
+
192
+ <LetterheadTextField
193
+ name={form.names.code}
194
+ label="Code"
195
+ placeholder="E.g. 123123"
196
+ autoComplete="one-time-code"
197
+ required
198
+ />
199
+
200
+ <LetterheadFormActions>
201
+ <LetterheadSubmitError name={submitName} />
202
+ <LetterheadSubmitButton>Verify</LetterheadSubmitButton>
203
+ </LetterheadFormActions>
204
+ </Letterhead>
205
+ </Form>
206
+ );
207
+ }
208
+
209
+ function SuccessStep() {
210
+ const { hrefs } = useAppConfig();
211
+ return (
212
+ <Letterhead>
213
+ <LetterheadHeader>
214
+ <LetterheadHeading>Success!</LetterheadHeading>
215
+ <LetterheadParagraph>
216
+ Your Indie Tabletop Club account has been verified, yay!
217
+ </LetterheadParagraph>
218
+ </LetterheadHeader>
219
+
220
+ <Link href={hrefs.dashboard()} {...cx(button())}>
221
+ Go to dashboard
222
+ </Link>
223
+ </Letterhead>
224
+ );
225
+ }
226
+
227
+ type JoinStep =
228
+ | { type: "INITIAL" }
229
+ | { type: "SUBMIT_CODE"; tokenId: string; email: string }
230
+ | { type: "SUCCESS" };
231
+
232
+ function JoinFlow(props: { defaultValues?: DefaultFormValues }) {
233
+ const [step, setStep] = useState<JoinStep>({ type: "INITIAL" });
234
+
235
+ switch (step.type) {
236
+ case "INITIAL": {
237
+ return <InitialStep {...props} setStep={setStep} />;
238
+ }
239
+
240
+ case "SUBMIT_CODE": {
241
+ return <SubmitCodeStep {...step} setStep={setStep} />;
242
+ }
243
+
244
+ case "SUCCESS": {
245
+ return <SuccessStep />;
246
+ }
247
+ }
248
+ }
249
+
250
+ export type JoinCardProps = {
251
+ onLogout: EventHandlerWithReload;
252
+ defaultValues?: DefaultFormValues;
253
+ };
254
+
255
+ /**
256
+ * Allows the user to join Indie Tabletop Club.
257
+ */
258
+ export function JoinCard(props: JoinCardProps) {
259
+ const { result, latestAttemptTs, reload } = useCurrentUserResult();
260
+
261
+ return result.unpack(
262
+ (currentUser) => {
263
+ return (
264
+ <AlreadyLoggedInView
265
+ {...props}
266
+ currentUser={currentUser}
267
+ reload={reload}
268
+ />
269
+ );
270
+ },
271
+ (failure) => {
272
+ if (failure.type === "API_ERROR" && failure.code === 401) {
273
+ return <JoinFlow {...props} />;
274
+ }
275
+
276
+ if (failure.type === "NETWORK_ERROR") {
277
+ return (
278
+ <NoConnectionView
279
+ latestAttemptTs={latestAttemptTs}
280
+ onRetry={() => reload()}
281
+ />
282
+ );
283
+ }
284
+
285
+ return <FailureFallbackView />;
286
+ },
287
+ () => {
288
+ return <LoadingView />;
289
+ },
290
+ );
291
+ }
@@ -0,0 +1,14 @@
1
+ import { cx } from "../class-names.ts";
2
+ import { Letterhead } from "../Letterhead/index.tsx";
3
+ import { LoadingIndicator } from "../LoadingIndicator.tsx";
4
+ import { loadingView } from "./style.css.ts";
5
+
6
+ export function LoadingView() {
7
+ return (
8
+ <Letterhead>
9
+ <div {...cx(loadingView.container)}>
10
+ <LoadingIndicator />
11
+ </div>
12
+ </Letterhead>
13
+ );
14
+ }