@indietabletop/appkit 3.6.0-2 → 3.6.0-3

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 (44) hide show
  1. package/lib/AppConfig/AppConfig.tsx +54 -0
  2. package/lib/HistoryState.ts +21 -0
  3. package/lib/Letterhead/style.css.ts +2 -0
  4. package/lib/LetterheadForm/index.tsx +53 -0
  5. package/lib/LetterheadForm/style.css.ts +8 -0
  6. package/lib/QRCode/QRCode.stories.tsx +47 -0
  7. package/lib/QRCode/QRCode.tsx +49 -0
  8. package/lib/QRCode/style.css.ts +19 -0
  9. package/lib/ShareButton/ShareButton.tsx +141 -0
  10. package/lib/SubscribeCard/LetterheadInfoCard.tsx +23 -0
  11. package/lib/SubscribeCard/SubscribeByEmailCard.stories.tsx +83 -0
  12. package/lib/SubscribeCard/SubscribeByEmailCard.tsx +177 -0
  13. package/lib/SubscribeCard/SubscribeCard.stories.tsx +27 -23
  14. package/lib/SubscribeCard/SubscribeCard.tsx +17 -16
  15. package/lib/Title/index.tsx +7 -2
  16. package/lib/account/AccountIssueView.tsx +40 -0
  17. package/lib/account/AlreadyLoggedInView.tsx +44 -0
  18. package/lib/account/CurrentUserFetcher.stories.tsx +339 -0
  19. package/lib/account/CurrentUserFetcher.tsx +119 -0
  20. package/lib/account/FailureFallbackView.tsx +36 -0
  21. package/lib/account/JoinPage.stories.tsx +270 -0
  22. package/lib/account/JoinPage.tsx +288 -0
  23. package/lib/account/LoadingView.tsx +14 -0
  24. package/lib/account/LoginPage.stories.tsx +318 -0
  25. package/lib/account/LoginPage.tsx +138 -0
  26. package/lib/account/LoginView.tsx +136 -0
  27. package/lib/account/NoConnectionView.tsx +34 -0
  28. package/lib/account/PasswordResetPage.stories.tsx +250 -0
  29. package/lib/account/PasswordResetPage.tsx +291 -0
  30. package/lib/account/UserMismatchView.tsx +61 -0
  31. package/lib/account/VerifyPage.tsx +217 -0
  32. package/lib/account/style.css.ts +57 -0
  33. package/lib/account/types.ts +9 -0
  34. package/lib/account/useCurrentUserResult.tsx +38 -0
  35. package/lib/class-names.ts +1 -1
  36. package/lib/client.ts +54 -7
  37. package/lib/globals.css.ts +5 -0
  38. package/lib/index.ts +11 -1
  39. package/lib/useEnsureValue.ts +31 -0
  40. package/package.json +3 -2
  41. package/lib/ClientContext/ClientContext.tsx +0 -25
  42. package/lib/LoginPage/LoginPage.stories.tsx +0 -107
  43. package/lib/LoginPage/LoginPage.tsx +0 -204
  44. package/lib/LoginPage/style.css.ts +0 -17
@@ -0,0 +1,270 @@
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 "./JoinPage.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
+ /**
140
+ * Allows the user to reset their password.
141
+ */
142
+ const meta = {
143
+ title: "Account/Join Page",
144
+ component: JoinCard,
145
+ tags: ["autodocs"],
146
+ args: {
147
+ onLogout: fn(),
148
+ },
149
+ parameters: {
150
+ msw: {
151
+ handlers: {
152
+ refresh: handlers.refreshTokens.failed(),
153
+ currentUser: handlers.getCurrentUser.notAuthenticated(),
154
+ join: handlers.join.success(data.john),
155
+ verify: handlers.verify.success(),
156
+ },
157
+ },
158
+ },
159
+ } satisfies Meta<typeof JoinCard>;
160
+
161
+ export default meta;
162
+
163
+ type Story = StoryObj<typeof meta>;
164
+
165
+ /**
166
+ * The default case in which all steps of the flow succeed.
167
+ */
168
+ export const Success: Story = {
169
+ play: async ({ canvas, userEvent }) => {
170
+ userEvent.type(canvas.getByTestId("email-input"), "foo@bar.com");
171
+ },
172
+ };
173
+
174
+ /**
175
+ * In this case, the initial step fails because the email address is associated
176
+ * with an existing user.
177
+ */
178
+ export const EmailTakenOnJoin: Story = {
179
+ parameters: {
180
+ msw: {
181
+ handlers: {
182
+ join: handlers.join.emailTaken(),
183
+ },
184
+ },
185
+ },
186
+ };
187
+
188
+ /**
189
+ * In this case, the initial step fails for a reason that doesn't have any
190
+ * special handling in this location. E.g. network error, server error, etc.
191
+ */
192
+ export const UnknownFailureOnJoin: Story = {
193
+ parameters: {
194
+ msw: {
195
+ handlers: {
196
+ join: handlers.join.unknownFailure(),
197
+ },
198
+ },
199
+ },
200
+ };
201
+
202
+ /**
203
+ * In this case, the verify step fails because the token has expired,
204
+ * or is incorrect.
205
+ */
206
+ export const NotFoundOrExpiredOnVerify: Story = {
207
+ parameters: {
208
+ msw: {
209
+ handlers: {
210
+ verify: handlers.verify.notFoundOrExpired(),
211
+ },
212
+ },
213
+ },
214
+ };
215
+
216
+ /**
217
+ * In this case, the verify step fails for a reason that doesn't have any
218
+ * special handling in this location. E.g. network error, server error, etc.
219
+ */
220
+ export const UnknownFailureOnVerify: Story = {
221
+ parameters: {
222
+ msw: {
223
+ handlers: {
224
+ verify: handlers.verify.unknownFailure(),
225
+ },
226
+ },
227
+ },
228
+ };
229
+
230
+ /**
231
+ * The proactive user-session check returns a user account.
232
+ *
233
+ * The user can continue to use the app, or log out.
234
+ */
235
+ export const AlreadyLoggedIn: Story = {
236
+ parameters: {
237
+ msw: {
238
+ handlers: {
239
+ currentUser: handlers.getCurrentUser.success(data.john),
240
+ },
241
+ },
242
+ },
243
+ };
244
+
245
+ /**
246
+ * The proactive user session check has failed due to connection issues.
247
+ */
248
+ export const NoConnection: Story = {
249
+ parameters: {
250
+ msw: {
251
+ handlers: {
252
+ currentUser: handlers.getCurrentUser.noConnection(),
253
+ },
254
+ },
255
+ },
256
+ };
257
+
258
+ /**
259
+ * The proactive user session check has failed due to an error that doesn't
260
+ * carry any special meaning.
261
+ */
262
+ export const UnknownFailure: Story = {
263
+ parameters: {
264
+ msw: {
265
+ handlers: {
266
+ currentUser: handlers.getCurrentUser.unknownFailure(),
267
+ },
268
+ },
269
+ },
270
+ };
@@ -0,0 +1,288 @@
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
+ data-testid="email-input"
91
+ required
92
+ />
93
+
94
+ <LetterheadTextField
95
+ name={form.names.password}
96
+ label="Password"
97
+ type="password"
98
+ placeholder="Choose a strong password"
99
+ hint="Must be at least 8 characters"
100
+ autoComplete="new-password"
101
+ data-testid="new-password-input"
102
+ required
103
+ />
104
+
105
+ <LetterheadCheckboxField
106
+ name={form.names.subscribedToNewsletter}
107
+ label={
108
+ <>
109
+ Subscribe to <em>The Changelog</em>, an at-most-once-a-month
110
+ newsletter about updates and new releases. You can unsubscribe
111
+ any time with one click.
112
+ </>
113
+ }
114
+ />
115
+
116
+ <LetterheadCheckboxField
117
+ name={form.names.acceptedTos}
118
+ required
119
+ label={
120
+ <>
121
+ I accept the{" "}
122
+ <a
123
+ target="_blank"
124
+ rel="noreferrer noopener"
125
+ href={hrefs.terms()}
126
+ className={interactiveText}
127
+ >
128
+ Terms of Service
129
+ </a>
130
+ .
131
+ </>
132
+ }
133
+ />
134
+ </InputsStack>
135
+
136
+ <LetterheadFormActions>
137
+ <LetterheadSubmitError name={submitName} />
138
+ <LetterheadSubmitButton>Join</LetterheadSubmitButton>
139
+
140
+ <LetterheadParagraph align="start">
141
+ {"Have an existing account? "}
142
+ <Link
143
+ {...cx(interactiveText)}
144
+ href={hrefs.login()}
145
+ state={{ emailValue }}
146
+ >
147
+ Log in
148
+ </Link>
149
+ {"."}
150
+ </LetterheadParagraph>
151
+ </LetterheadFormActions>
152
+ </Form>
153
+ </Letterhead>
154
+ );
155
+ }
156
+
157
+ function SubmitCodeStep(props: {
158
+ tokenId: string;
159
+ setStep: SetStep;
160
+ email: string;
161
+ }) {
162
+ const { tokenId, email, setStep } = props;
163
+ const client = useClient();
164
+
165
+ const { form, submitName } = useForm({
166
+ defaultValues: {
167
+ code: "",
168
+ },
169
+ async onSubmit({ values }) {
170
+ const op = await client.verifyUser({ ...values, tokenId });
171
+
172
+ return op.mapFailure((failure) => {
173
+ return getSubmitFailureMessage(failure, {
174
+ 404: "🚫 This code is incorrect or expired. Please try again.",
175
+ });
176
+ });
177
+ },
178
+ onSuccess() {
179
+ setStep({ type: "SUCCESS" });
180
+ },
181
+ });
182
+
183
+ return (
184
+ <Form store={form} resetOnSubmit={false}>
185
+ <Letterhead>
186
+ <LetterheadHeader>
187
+ <LetterheadHeading>Verify account</LetterheadHeading>
188
+ <LetterheadParagraph>
189
+ We've sent a one-time code to <em>{email}</em>. Please, enter the
190
+ code in the field below to verify your account.
191
+ </LetterheadParagraph>
192
+ </LetterheadHeader>
193
+
194
+ <LetterheadTextField
195
+ name={form.names.code}
196
+ label="Code"
197
+ placeholder="E.g. 123123"
198
+ autoComplete="one-time-code"
199
+ required
200
+ />
201
+
202
+ <LetterheadFormActions>
203
+ <LetterheadSubmitError name={submitName} />
204
+ <LetterheadSubmitButton>Verify</LetterheadSubmitButton>
205
+ </LetterheadFormActions>
206
+ </Letterhead>
207
+ </Form>
208
+ );
209
+ }
210
+
211
+ function SuccessStep() {
212
+ const { hrefs } = useAppConfig();
213
+ return (
214
+ <Letterhead>
215
+ <LetterheadHeader>
216
+ <LetterheadHeading>Success!</LetterheadHeading>
217
+ <LetterheadParagraph>
218
+ Your Indie Tabletop Club account has been verified, yay!
219
+ </LetterheadParagraph>
220
+ </LetterheadHeader>
221
+
222
+ <Link href={hrefs.dashboard()} {...cx(button())}>
223
+ Go to dashboard
224
+ </Link>
225
+ </Letterhead>
226
+ );
227
+ }
228
+
229
+ type JoinStep =
230
+ | { type: "INITIAL" }
231
+ | { type: "SUBMIT_CODE"; tokenId: string; email: string }
232
+ | { type: "SUCCESS" };
233
+
234
+ function JoinFlow(props: { defaultValues?: DefaultFormValues }) {
235
+ const [step, setStep] = useState<JoinStep>({ type: "INITIAL" });
236
+
237
+ switch (step.type) {
238
+ case "INITIAL": {
239
+ return <InitialStep {...props} setStep={setStep} />;
240
+ }
241
+
242
+ case "SUBMIT_CODE": {
243
+ return <SubmitCodeStep {...step} setStep={setStep} />;
244
+ }
245
+
246
+ case "SUCCESS": {
247
+ return <SuccessStep />;
248
+ }
249
+ }
250
+ }
251
+
252
+ export function JoinCard(props: {
253
+ onLogout: EventHandlerWithReload;
254
+ defaultValues?: DefaultFormValues;
255
+ }) {
256
+ const { result, latestAttemptTs, reload } = useCurrentUserResult();
257
+
258
+ return result.unpack(
259
+ (currentUser) => {
260
+ return (
261
+ <AlreadyLoggedInView
262
+ {...props}
263
+ currentUser={currentUser}
264
+ reload={reload}
265
+ />
266
+ );
267
+ },
268
+ (failure) => {
269
+ if (failure.type === "API_ERROR" && failure.code === 401) {
270
+ return <JoinFlow {...props} />;
271
+ }
272
+
273
+ if (failure.type === "NETWORK_ERROR") {
274
+ return (
275
+ <NoConnectionView
276
+ latestAttemptTs={latestAttemptTs}
277
+ onRetry={() => reload()}
278
+ />
279
+ );
280
+ }
281
+
282
+ return <FailureFallbackView />;
283
+ },
284
+ () => {
285
+ return <LoadingView />;
286
+ },
287
+ );
288
+ }
@@ -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
+ }