@indietabletop/appkit 7.0.0-rc.0 → 7.0.0-rc.2
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/formatters.tsx +5 -0
- package/lib/ListView/ListView.stories.tsx +36 -0
- package/lib/ListView/ListView.tsx +80 -0
- package/lib/ListView/style.css.ts +90 -0
- package/lib/RichText/RichText.tsx +55 -0
- package/lib/RichText/style.css.ts +147 -0
- package/lib/account/JoinCard.tsx +6 -2
- package/lib/account/LoginView.tsx +9 -6
- package/lib/async-op.ts +15 -3
- package/lib/createStrictContext.ts +2 -2
- package/lib/fathom.ts +15 -0
- package/lib/hrefs.ts +28 -4
- package/lib/index.ts +6 -0
- package/lib/layers.css.ts +3 -0
- package/lib/omitUndefinedKeys.ts +9 -0
- package/lib/store/index.tsx +2 -4
- package/lib/useFetchJson.tsx +52 -0
- package/lib/useGetProductStatus.ts +56 -0
- package/lib/utm.ts +1 -9
- package/package.json +1 -1
|
@@ -15,6 +15,8 @@ export function createFormatters(locale: EnglishLocale) {
|
|
|
15
15
|
|
|
16
16
|
const dateTimeFmt = new Intl.DateTimeFormat(locale);
|
|
17
17
|
|
|
18
|
+
const dateFmt = new Intl.DateTimeFormat(locale, { dateStyle: "long" });
|
|
19
|
+
|
|
18
20
|
const conjunctionFmt = new Intl.ListFormat(locale, {
|
|
19
21
|
style: "long",
|
|
20
22
|
type: "conjunction",
|
|
@@ -33,6 +35,9 @@ export function createFormatters(locale: EnglishLocale) {
|
|
|
33
35
|
dateTime(date: Date | string | number) {
|
|
34
36
|
return dateTimeFmt.format(new Date(date));
|
|
35
37
|
},
|
|
38
|
+
date(date: Date | string | number) {
|
|
39
|
+
return dateFmt.format(new Date(date));
|
|
40
|
+
},
|
|
36
41
|
conjunction(items: string[]) {
|
|
37
42
|
return conjunctionFmt.format(items);
|
|
38
43
|
},
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import preview from "../../.storybook/preview.tsx";
|
|
2
|
+
import { ListItem, ListView } from "./ListView.tsx";
|
|
3
|
+
|
|
4
|
+
const meta = preview.meta({
|
|
5
|
+
title: "Components/ListView",
|
|
6
|
+
component: ListView,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const Default = meta.story({
|
|
10
|
+
args: {
|
|
11
|
+
children: (
|
|
12
|
+
<>
|
|
13
|
+
<ListItem heading="Item 1" summary="Summary for item 1" />
|
|
14
|
+
<ListItem heading="Item 2" summary="Summary for item 2" type="Type" />
|
|
15
|
+
<ListItem
|
|
16
|
+
heading="Item 2"
|
|
17
|
+
summary="Summary for item 2"
|
|
18
|
+
type="Type"
|
|
19
|
+
aside={
|
|
20
|
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
21
|
+
<rect width="40" height="40" fill="rebeccapurple" />
|
|
22
|
+
<circle cx="20" cy="20" r="10" fill="white" />
|
|
23
|
+
</svg>
|
|
24
|
+
}
|
|
25
|
+
/>
|
|
26
|
+
</>
|
|
27
|
+
),
|
|
28
|
+
fallback: "Empty",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const Empty = meta.story({
|
|
33
|
+
args: {
|
|
34
|
+
fallback: "This list is empty",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, cloneElement } from "react";
|
|
2
|
+
import { cx } from "../class-names.ts";
|
|
3
|
+
import { MiddotSeparated } from "../MiddotSeparated/MiddotSeparated.tsx";
|
|
4
|
+
import { item, list } from "./style.css.ts";
|
|
5
|
+
|
|
6
|
+
export { listViewTheme } from "./style.css.ts";
|
|
7
|
+
|
|
8
|
+
export function ListItemContainer(props: {
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}) {
|
|
12
|
+
return <li {...cx(props, item.container)}>{props.children}</li>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ListItemContent(props: {
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
render?: ReactElement;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const element = props.render ?? <div />;
|
|
21
|
+
return cloneElement(element, cx(props, item.content), props.children);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ListItemHeading(props: {
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
}) {
|
|
28
|
+
return <div {...cx(props, item.heading)}>{props.children}</div>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ListItemSummary(props: {
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
className?: string;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<MiddotSeparated {...cx(props, item.summary)}>
|
|
37
|
+
{props.children}
|
|
38
|
+
</MiddotSeparated>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ListItem(props: {
|
|
43
|
+
heading: string;
|
|
44
|
+
type?: string;
|
|
45
|
+
summary: ReactNode;
|
|
46
|
+
render?: ReactElement;
|
|
47
|
+
aside?: ReactNode;
|
|
48
|
+
}) {
|
|
49
|
+
const { render, heading, type, summary, aside } = props;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ListItemContainer>
|
|
53
|
+
<ListItemContent render={render}>
|
|
54
|
+
<ListItemHeading>{heading}</ListItemHeading>
|
|
55
|
+
|
|
56
|
+
<ListItemSummary>
|
|
57
|
+
{type && <em>{type}</em>}
|
|
58
|
+
{summary}
|
|
59
|
+
</ListItemSummary>
|
|
60
|
+
</ListItemContent>
|
|
61
|
+
|
|
62
|
+
{aside}
|
|
63
|
+
</ListItemContainer>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ListView(props: {
|
|
68
|
+
children?: ReactNode;
|
|
69
|
+
fallback?: ReactNode;
|
|
70
|
+
className?: string;
|
|
71
|
+
}) {
|
|
72
|
+
const { children, fallback } = props;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<ul {...cx(props, list.container)}>{children}</ul>
|
|
77
|
+
<div className={list.empty}>{fallback}</div>
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createThemeContract, fallbackVar, style } from "@vanilla-extract/css";
|
|
2
|
+
import { appkit } from "../layers.css.ts";
|
|
3
|
+
|
|
4
|
+
export const listViewTheme = createThemeContract({
|
|
5
|
+
headingFont: null,
|
|
6
|
+
headingSize: null,
|
|
7
|
+
summaryFont: null,
|
|
8
|
+
summarySize: null,
|
|
9
|
+
blockSpace: null,
|
|
10
|
+
inlineSpace: null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const item = {
|
|
14
|
+
container: style({
|
|
15
|
+
"@layer": {
|
|
16
|
+
[appkit]: {
|
|
17
|
+
display: "flex",
|
|
18
|
+
gap: fallbackVar(listViewTheme.inlineSpace, "1rem"),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
content: style({
|
|
24
|
+
"@layer": {
|
|
25
|
+
[appkit]: {
|
|
26
|
+
flexGrow: 1,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
|
|
31
|
+
heading: style({
|
|
32
|
+
"@layer": {
|
|
33
|
+
[appkit]: {
|
|
34
|
+
fontFamily: listViewTheme.headingFont,
|
|
35
|
+
fontSize: fallbackVar(listViewTheme.headingSize, "1.25rem"),
|
|
36
|
+
lineHeight: "1lh",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
summary: style({
|
|
42
|
+
"@layer": {
|
|
43
|
+
[appkit]: {
|
|
44
|
+
fontFamily: listViewTheme.summaryFont,
|
|
45
|
+
fontSize: fallbackVar(listViewTheme.summarySize, "1rem"),
|
|
46
|
+
marginBlockStart: "0.5em",
|
|
47
|
+
lineHeight: 1.25,
|
|
48
|
+
|
|
49
|
+
":empty": {
|
|
50
|
+
display: "none",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const listContainerHook = style({});
|
|
58
|
+
|
|
59
|
+
export const list = {
|
|
60
|
+
container: style([
|
|
61
|
+
listContainerHook,
|
|
62
|
+
{
|
|
63
|
+
"@layer": {
|
|
64
|
+
[appkit]: {
|
|
65
|
+
display: "flex",
|
|
66
|
+
flexDirection: "column",
|
|
67
|
+
gap: fallbackVar(listViewTheme.blockSpace, "1.5rem"),
|
|
68
|
+
|
|
69
|
+
":empty": {
|
|
70
|
+
display: "none",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
]),
|
|
76
|
+
|
|
77
|
+
empty: style({
|
|
78
|
+
"@layer": {
|
|
79
|
+
[appkit]: {
|
|
80
|
+
display: "none",
|
|
81
|
+
|
|
82
|
+
selectors: {
|
|
83
|
+
[`${listContainerHook}:empty + &`]: {
|
|
84
|
+
display: "initial",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useLocation } from "wouter";
|
|
2
|
+
import { cx } from "../class-names.ts";
|
|
3
|
+
import type { TrustedHtml } from "../types.ts";
|
|
4
|
+
import { content } from "./style.css.ts";
|
|
5
|
+
|
|
6
|
+
// export { richTextTheme } from "./style.css.ts";
|
|
7
|
+
|
|
8
|
+
export function RichText(props: {
|
|
9
|
+
trustedHtml: TrustedHtml;
|
|
10
|
+
className?: string;
|
|
11
|
+
}) {
|
|
12
|
+
const [_, navigate] = useLocation();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
{...cx(props, content)}
|
|
17
|
+
dangerouslySetInnerHTML={{ __html: props.trustedHtml }}
|
|
18
|
+
onClick={(event) => {
|
|
19
|
+
if (!(event.target instanceof HTMLElement)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const anchor = event.target.closest("a");
|
|
24
|
+
if (!anchor) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The actual value in the HTML
|
|
29
|
+
const href = anchor.getAttribute("href");
|
|
30
|
+
if (!href) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (href.startsWith("#")) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (href.startsWith("/")) {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
event.stopPropagation();
|
|
41
|
+
navigate(`~${href}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
const tempLink = document.createElement("a");
|
|
48
|
+
tempLink.href = anchor.href;
|
|
49
|
+
tempLink.target = "_blank";
|
|
50
|
+
tempLink.rel = "noopener noreferrer";
|
|
51
|
+
tempLink.click();
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createThemeContract, globalStyle, style } from "@vanilla-extract/css";
|
|
2
|
+
import { Hover, MinWidth } from "../media.ts";
|
|
3
|
+
|
|
4
|
+
export const richTextTheme = createThemeContract({
|
|
5
|
+
headingFont: null,
|
|
6
|
+
bodyFont: null,
|
|
7
|
+
emphasisFont: null,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const content = style({
|
|
11
|
+
fontFamily: richTextTheme.bodyFont,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
globalStyle(`${content} > :last-child`, {
|
|
15
|
+
marginBlockEnd: "0",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
globalStyle(`${content} > :first-child`, {
|
|
19
|
+
marginBlockStart: "0",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
globalStyle(`${content} em`, {
|
|
23
|
+
fontFamily: richTextTheme.emphasisFont,
|
|
24
|
+
textTransform: "lowercase",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
globalStyle(`${content} :is(h1, h2, h3, h4, h5, h6)`, {
|
|
28
|
+
fontFamily: richTextTheme.headingFont,
|
|
29
|
+
lineHeight: 1.25,
|
|
30
|
+
fontWeight: 600,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
globalStyle(`${content} h2`, {
|
|
34
|
+
fontSize: "1.75rem",
|
|
35
|
+
marginBlockStart: "1lh",
|
|
36
|
+
marginBlockEnd: "0.5lh",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
globalStyle(`${content} h3`, {
|
|
40
|
+
fontSize: "1.5rem",
|
|
41
|
+
marginBlockStart: "1lh",
|
|
42
|
+
marginBlockEnd: "0.5lh",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
globalStyle(`${content} h4`, {
|
|
46
|
+
fontSize: "1.125rem",
|
|
47
|
+
marginBlockStart: "1lh",
|
|
48
|
+
marginBlockEnd: "0.5lh",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
globalStyle(`${content} h5`, {
|
|
52
|
+
fontSize: "1rem",
|
|
53
|
+
marginBlockStart: "1lh",
|
|
54
|
+
marginBlockEnd: "0.5lh",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
globalStyle(`${content} :is(p, li)`, {
|
|
58
|
+
fontSize: "1rem",
|
|
59
|
+
lineHeight: 1.5,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
globalStyle(`${content} :is(ul, ol, p)`, {
|
|
63
|
+
marginBlock: "0.5lh",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
globalStyle(`${content} :is(ul, ol)`, {
|
|
67
|
+
paddingInlineStart: "1.25em",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
globalStyle(`${content} ul`, {
|
|
71
|
+
listStyle: "disc",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
globalStyle(`${content} ol`, {
|
|
75
|
+
listStyle: "decimal",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
globalStyle(`${content} hr`, {
|
|
79
|
+
marginBlock: "2.5lh",
|
|
80
|
+
borderBlockStart: `1px dashed currentcolor`,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
globalStyle(`${content} blockquote`, {
|
|
84
|
+
marginInlineStart: "0",
|
|
85
|
+
paddingInlineStart: "1lh",
|
|
86
|
+
borderInlineStart: `1px dashed currentcolor`,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
globalStyle(`${content} a`, {
|
|
90
|
+
display: "inline",
|
|
91
|
+
textDecoration: "underline",
|
|
92
|
+
textDecorationThickness: "1px",
|
|
93
|
+
textUnderlineOffset: "0.125em",
|
|
94
|
+
textDecorationColor: "hsl(from currentcolor h s calc(l + 50))",
|
|
95
|
+
transition: "text-decoration-color 200ms",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
globalStyle(`${content} a:hover`, {
|
|
99
|
+
"@media": {
|
|
100
|
+
[Hover.HOVER]: {
|
|
101
|
+
transition: "text-decoration-color 100ms",
|
|
102
|
+
textDecorationColor: "currentcolor",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
globalStyle(`${content} img`, {
|
|
108
|
+
display: "block",
|
|
109
|
+
maxInlineSize: "100%",
|
|
110
|
+
marginBlock: "1lh",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
globalStyle(`${content} img[src *= "rounded"]`, {
|
|
114
|
+
borderRadius: "0.25rem",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
globalStyle(`${content} img[src*="float"]`, {
|
|
118
|
+
"@media": {
|
|
119
|
+
[MinWidth.MEDIUM]: {
|
|
120
|
+
margin: "0 0 1lh 1lh",
|
|
121
|
+
float: "inline-end",
|
|
122
|
+
maxInlineSize: "50%",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
globalStyle(`${content} img[src*="rotate"]`, {
|
|
128
|
+
"@media": {
|
|
129
|
+
[MinWidth.MEDIUM]: {
|
|
130
|
+
rotate: "3deg",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
globalStyle(`${content} code`, {
|
|
136
|
+
wordBreak: "break-word",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
globalStyle(`${content} [src $= "?icon"]`, {
|
|
140
|
+
position: "relative",
|
|
141
|
+
display: "inline-block",
|
|
142
|
+
backgroundColor: "white",
|
|
143
|
+
width: "1.25em",
|
|
144
|
+
borderRadius: "0.15em",
|
|
145
|
+
border: "1px solid #e2e2e2",
|
|
146
|
+
marginBlock: "-0.25em",
|
|
147
|
+
});
|
package/lib/account/JoinCard.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Form, useStoreState } from "@ariakit/react";
|
|
2
2
|
import { type Dispatch, type SetStateAction, useState } from "react";
|
|
3
|
-
import { Link } from "wouter";
|
|
3
|
+
import { Link, useSearchParams } from "wouter";
|
|
4
4
|
import { useAppConfig, useClient } from "../AppConfig/AppConfig.tsx";
|
|
5
5
|
import { cx } from "../class-names.ts";
|
|
6
6
|
import { interactiveText } from "../common.css.ts";
|
|
@@ -38,6 +38,7 @@ type InitialStepProps = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
function InitialStep(props: InitialStepProps) {
|
|
41
|
+
const [params] = useSearchParams();
|
|
41
42
|
const { client, placeholders, hrefs } = useAppConfig();
|
|
42
43
|
const { defaultValues, setStep } = props;
|
|
43
44
|
|
|
@@ -142,7 +143,10 @@ function InitialStep(props: InitialStepProps) {
|
|
|
142
143
|
{"Have an existing account? "}
|
|
143
144
|
<Link
|
|
144
145
|
{...cx(interactiveText)}
|
|
145
|
-
href={hrefs.login(
|
|
146
|
+
href={hrefs.login({
|
|
147
|
+
redirectTo: params.get("redirectTo") ?? undefined,
|
|
148
|
+
backTo: params.get("backTo") ?? undefined,
|
|
149
|
+
})}
|
|
146
150
|
state={{ emailValue }}
|
|
147
151
|
>
|
|
148
152
|
Log in
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Button, Form, useStoreState } from "@ariakit/react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import { Link, useLocation } from "wouter";
|
|
3
|
+
import { Link, useLocation, useSearchParams } from "wouter";
|
|
4
4
|
import { useAppConfig } from "../AppConfig/AppConfig.tsx";
|
|
5
5
|
import { interactiveText } from "../common.css.ts";
|
|
6
6
|
import { getSubmitFailureMessage } from "../failureMessages.ts";
|
|
@@ -31,6 +31,7 @@ export function LoginView(props: {
|
|
|
31
31
|
description: ReactNode;
|
|
32
32
|
reload: () => void;
|
|
33
33
|
}) {
|
|
34
|
+
const [params] = useSearchParams();
|
|
34
35
|
const { currentUser, defaultValues, description, onLogin, reload } = props;
|
|
35
36
|
const { placeholders, client, hrefs } = useAppConfig();
|
|
36
37
|
const { logout } = useAppActions();
|
|
@@ -73,7 +74,7 @@ export function LoginView(props: {
|
|
|
73
74
|
<LetterheadHeader>
|
|
74
75
|
<LetterheadHeading>Log in</LetterheadHeading>
|
|
75
76
|
|
|
76
|
-
{localUserPresent ?
|
|
77
|
+
{localUserPresent ?
|
|
77
78
|
<>
|
|
78
79
|
<LetterheadParagraph>
|
|
79
80
|
Your session has expired. Please log into Indie Tabletop Club
|
|
@@ -94,12 +95,14 @@ export function LoginView(props: {
|
|
|
94
95
|
{" first."}
|
|
95
96
|
</LetterheadParagraph>
|
|
96
97
|
</>
|
|
97
|
-
|
|
98
|
-
<LetterheadParagraph>
|
|
98
|
+
: <LetterheadParagraph>
|
|
99
99
|
{description}
|
|
100
100
|
{" Do not have an account? "}
|
|
101
101
|
<Link
|
|
102
|
-
href={hrefs.join(
|
|
102
|
+
href={hrefs.join({
|
|
103
|
+
redirectTo: params.get("redirectTo") ?? undefined,
|
|
104
|
+
backTo: params.get("backTo") ?? undefined,
|
|
105
|
+
})}
|
|
103
106
|
className={interactiveText}
|
|
104
107
|
state={{ emailValue }}
|
|
105
108
|
>
|
|
@@ -107,7 +110,7 @@ export function LoginView(props: {
|
|
|
107
110
|
</Link>
|
|
108
111
|
{"."}
|
|
109
112
|
</LetterheadParagraph>
|
|
110
|
-
|
|
113
|
+
}
|
|
111
114
|
</LetterheadHeader>
|
|
112
115
|
|
|
113
116
|
<Form store={form} resetOnSubmit={false}>
|
package/lib/async-op.ts
CHANGED
|
@@ -242,9 +242,11 @@ export function fold<Ops extends readonly AsyncOp<unknown, unknown>[] | []>(
|
|
|
242
242
|
ops: Ops,
|
|
243
243
|
): AsyncOp<
|
|
244
244
|
{
|
|
245
|
-
-readonly [Index in keyof Ops]: Ops[Index] extends
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
-readonly [Index in keyof Ops]: Ops[Index] extends (
|
|
246
|
+
AsyncOp<infer S, unknown>
|
|
247
|
+
) ?
|
|
248
|
+
S
|
|
249
|
+
: never;
|
|
248
250
|
},
|
|
249
251
|
Ops[number] extends AsyncOp<unknown, infer F> ? F : never
|
|
250
252
|
> {
|
|
@@ -284,3 +286,13 @@ export function fromTryCatch<T>(callback: () => T) {
|
|
|
284
286
|
return new Failure(cause);
|
|
285
287
|
}
|
|
286
288
|
}
|
|
289
|
+
|
|
290
|
+
export async function fromPromise<T>(
|
|
291
|
+
callback: () => Promise<T>,
|
|
292
|
+
): Promise<Success<T> | Failure<unknown>> {
|
|
293
|
+
try {
|
|
294
|
+
return new Success(await callback());
|
|
295
|
+
} catch (cause) {
|
|
296
|
+
return new Failure(cause);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createContext, use } from "react";
|
|
2
2
|
|
|
3
|
-
export function createStrictContext<T>() {
|
|
3
|
+
export function createStrictContext<T>(debugName: string = "Value") {
|
|
4
4
|
const Context = createContext<T | null>(null);
|
|
5
5
|
|
|
6
6
|
const useStrictContext = () => {
|
|
7
7
|
const value = use(Context);
|
|
8
8
|
if (!value) {
|
|
9
|
-
throw new Error(
|
|
9
|
+
throw new Error(`${debugName} not found in context.`);
|
|
10
10
|
}
|
|
11
11
|
return value;
|
|
12
12
|
};
|
package/lib/fathom.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
fathom?: { trackEvent: (name: string) => void };
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function trackEvent(event: string) {
|
|
8
|
+
if (window.fathom) {
|
|
9
|
+
window.fathom.trackEvent(event);
|
|
10
|
+
} else {
|
|
11
|
+
console.warn(
|
|
12
|
+
`Attempting to track event ${event}, but Fathom not installed.`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
package/lib/hrefs.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import type { LinkUtmParams, createUtm } from "./utm.ts";
|
|
2
2
|
|
|
3
|
+
export function withParams(path: string, params?: Record<string, string>) {
|
|
4
|
+
if (!params) {
|
|
5
|
+
return path;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return `${path}?${new URLSearchParams(params)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AccountParams = {
|
|
12
|
+
redirectTo?: string;
|
|
13
|
+
backTo?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BuyParams = {
|
|
17
|
+
note?: "continuePurchase" | "loginNeeded";
|
|
18
|
+
};
|
|
19
|
+
|
|
3
20
|
type InputAppHrefs = {
|
|
4
|
-
login
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
21
|
+
login?: (params?: AccountParams) => string;
|
|
22
|
+
join?: (params?: AccountParams) => string;
|
|
23
|
+
password?: () => string;
|
|
24
|
+
|
|
25
|
+
dashboard?: () => string;
|
|
8
26
|
account: () => string;
|
|
9
27
|
|
|
10
28
|
// These are usually external links to the root domain, so we want to be
|
|
@@ -33,6 +51,12 @@ export function createHrefs<T extends InputAppHrefs>(params: {
|
|
|
33
51
|
const { utm, hrefs } = params;
|
|
34
52
|
|
|
35
53
|
return {
|
|
54
|
+
dashboard: () => `~/`,
|
|
55
|
+
|
|
56
|
+
login: (params?: AccountParams) => withParams(`~/login`, params),
|
|
57
|
+
join: (params?: AccountParams) => withParams(`~/join`, params),
|
|
58
|
+
password: () => `~/password`,
|
|
59
|
+
|
|
36
60
|
terms: (linkUtm?: LinkUtmParams) =>
|
|
37
61
|
`https://indietabletop.club/terms?${utm(linkUtm)}`,
|
|
38
62
|
privacy: (linkUtm?: LinkUtmParams) =>
|
package/lib/index.ts
CHANGED
|
@@ -17,11 +17,13 @@ export * from "./IndieTabletopClubLogo.tsx";
|
|
|
17
17
|
export * from "./IndieTabletopClubSymbol.tsx";
|
|
18
18
|
export * from "./Letterhead/index.tsx";
|
|
19
19
|
export * from "./LetterheadForm/index.tsx";
|
|
20
|
+
export * from "./ListView/ListView.tsx";
|
|
20
21
|
export * from "./LoadingIndicator.tsx";
|
|
21
22
|
export * from "./MiddotSeparated/MiddotSeparated.tsx";
|
|
22
23
|
export * from "./ModalDialog/index.tsx";
|
|
23
24
|
export * from "./QRCode/QRCode.tsx";
|
|
24
25
|
export * from "./ReleaseInfo/index.tsx";
|
|
26
|
+
export * from "./RichText/RichText.tsx";
|
|
25
27
|
export * from "./SafariCheck/SafariCheck.tsx";
|
|
26
28
|
export * from "./ServiceWorkerHandler.tsx";
|
|
27
29
|
export * from "./ShareButton/ShareButton.tsx";
|
|
@@ -40,10 +42,13 @@ export * from "./use-media-query.ts";
|
|
|
40
42
|
export * from "./use-reverting-state.ts";
|
|
41
43
|
export * from "./use-scroll-restoration.ts";
|
|
42
44
|
export * from "./useEnsureValue.ts";
|
|
45
|
+
export * from "./useFetchJson.tsx";
|
|
46
|
+
export * from "./useGetProductStatus.ts";
|
|
43
47
|
export * from "./useInvokeClient.ts";
|
|
44
48
|
export * from "./useIsVisible.ts";
|
|
45
49
|
|
|
46
50
|
// Utils
|
|
51
|
+
|
|
47
52
|
export * from "./append-copy-to-text.ts";
|
|
48
53
|
export * from "./async-op.ts";
|
|
49
54
|
export * from "./caught-value.ts";
|
|
@@ -52,6 +57,7 @@ export * from "./client.ts";
|
|
|
52
57
|
export * from "./copyrightRange.ts";
|
|
53
58
|
export * from "./createSafeStorage.ts";
|
|
54
59
|
export * from "./failureMessages.ts";
|
|
60
|
+
export * from "./fathom.ts";
|
|
55
61
|
export * from "./groupBy.ts";
|
|
56
62
|
export * from "./HistoryState.ts";
|
|
57
63
|
export * from "./hrefs.ts";
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
export 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
|
+
}
|
package/lib/store/index.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { UserGameData } from "@indietabletop/types";
|
|
2
2
|
import { useActorRef, useSelector } from "@xstate/react";
|
|
3
|
-
import {
|
|
3
|
+
import { useMemo, type ReactNode } from "react";
|
|
4
4
|
import { Actor, fromCallback, fromPromise } from "xstate";
|
|
5
5
|
import { useAppConfig } from "../AppConfig/AppConfig.tsx";
|
|
6
6
|
import { Failure, Success } from "../async-op.ts";
|
|
@@ -25,9 +25,7 @@ type AppActor = Actor<AppMachine>;
|
|
|
25
25
|
export const [AppMachineContext, useAppMachineContext] = createStrictContext<{
|
|
26
26
|
actorRef: AppActor;
|
|
27
27
|
actions: AppActions;
|
|
28
|
-
}>();
|
|
29
|
-
|
|
30
|
-
export const AppActionsContext = createContext<null | AppActions>(null);
|
|
28
|
+
}>("App Machine");
|
|
31
29
|
|
|
32
30
|
export function useCurrentUser() {
|
|
33
31
|
const { actorRef } = useAppMachineContext();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import useSWR, { type SWRConfiguration } from "swr";
|
|
2
|
+
import { Failure, fromPromise, Success } from "./async-op.ts";
|
|
3
|
+
import { swrResponseToResult } from "./result/swr.ts";
|
|
4
|
+
import type { FailurePayload } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export type DocContentTypeMismatch = {
|
|
7
|
+
type: "DOC_CONTENT_TYPE_MISMATCH";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type FetchJsonResult<T> =
|
|
11
|
+
| Success<T>
|
|
12
|
+
| Failure<FailurePayload | DocContentTypeMismatch>;
|
|
13
|
+
|
|
14
|
+
export async function fetchJson<T>(url: string): Promise<FetchJsonResult<T>> {
|
|
15
|
+
const result = await fromPromise(() => fetch(url));
|
|
16
|
+
|
|
17
|
+
if (result.isFailure) {
|
|
18
|
+
return new Failure({ type: "NETWORK_ERROR" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { value: response } = result;
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
return new Failure({ type: "API_ERROR", code: response.status });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// This can happen due to Netlify rewrites serving index.html in case the
|
|
27
|
+
// requested doc file is not found.
|
|
28
|
+
if (response.headers.get("Content-Type") !== "application/json") {
|
|
29
|
+
return new Failure({ type: "DOC_CONTENT_TYPE_MISMATCH" });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parsed = await fromPromise(() => response.json());
|
|
33
|
+
if (parsed.isFailure) {
|
|
34
|
+
return new Failure({ type: "UNKNOWN_ERROR" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useFetchJson<T = unknown>(
|
|
41
|
+
url: string,
|
|
42
|
+
config?: SWRConfiguration,
|
|
43
|
+
) {
|
|
44
|
+
return useSWR<FetchJsonResult<T>>(url, fetchJson, config);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useFetchJsonResult<T = unknown>(
|
|
48
|
+
url: string,
|
|
49
|
+
config?: SWRConfiguration,
|
|
50
|
+
) {
|
|
51
|
+
return swrResponseToResult(useFetchJson<T>(url, config));
|
|
52
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import useSWR from "swr";
|
|
3
|
+
import { useClient } from "./AppConfig/AppConfig.tsx";
|
|
4
|
+
import { Failure, Success } from "./async-op.ts";
|
|
5
|
+
import { swrResponseToResult } from "./result/swr.ts";
|
|
6
|
+
import { useCurrentUser } from "./store/index.tsx";
|
|
7
|
+
|
|
8
|
+
function useGetOwnedProduct(productCode: string) {
|
|
9
|
+
const client = useClient();
|
|
10
|
+
|
|
11
|
+
const swr = useSWR(
|
|
12
|
+
`getOwnedProduct:${productCode}`,
|
|
13
|
+
useCallback(() => {
|
|
14
|
+
return client.getOwnedProduct(productCode);
|
|
15
|
+
}, [productCode]),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
return swrResponseToResult(swr);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useGetProductStatus(productCode: string) {
|
|
22
|
+
const result = useGetOwnedProduct(productCode);
|
|
23
|
+
const currentUser = useCurrentUser();
|
|
24
|
+
|
|
25
|
+
if (result.isPending) {
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (result.isFailure) {
|
|
30
|
+
const { failure } = result;
|
|
31
|
+
|
|
32
|
+
if (failure.type === "API_ERROR") {
|
|
33
|
+
// The no currentUser case is possible in case the user is logged into ITC
|
|
34
|
+
// via a domain-wide cookie, but they have not yet logged in this app.
|
|
35
|
+
if (failure.code === 401 || !currentUser) {
|
|
36
|
+
return new Success({ type: "AUTHENTICATE" as const });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (failure.code === 403) {
|
|
40
|
+
return new Success({ type: "PURCHASE" as const });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Other errors should be handled by the default error handling
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// This shouldn't happen in practice, as this particular endpoint errors out
|
|
49
|
+
// if a product link fails to generate.
|
|
50
|
+
const { downloadUrl } = result.value;
|
|
51
|
+
if (!downloadUrl) {
|
|
52
|
+
return new Failure({ type: "UNKNOWN_ERROR" as const });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return new Success({ type: "DOWNLOAD" as const, downloadUrl });
|
|
56
|
+
}
|
package/lib/utm.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
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
|
-
}
|
|
1
|
+
import { omitUndefinedKeys } from "./omitUndefinedKeys.ts";
|
|
10
2
|
|
|
11
3
|
/**
|
|
12
4
|
* Full UTM configuration object.
|