@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.
@@ -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 take a tuple of Dialog a DialogDisclosure
19
- * elements as children, and makes sense that the Dialog component is not
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
+ }
@@ -17,6 +17,7 @@ export function LoadingIndicator(props: { className?: string }) {
17
17
  width={width}
18
18
  height={height}
19
19
  className={props.className}
20
+ preserveAspectRatio="xMidYMid meet"
20
21
  >
21
22
  <g stroke="none" fill="inherit">
22
23
  {Array.from({ length: 3 }, (_, index) => {
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const withMiddlot = style({
4
+ selectors: {
5
+ "& + &::before": {
6
+ content: " · ",
7
+ opacity: 0.6,
8
+ },
9
+ },
10
+ });
@@ -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
- {props.children}
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
- export function CurrentUserFetcher(props: {
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 "./JoinPage.tsx";
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 Page",
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 function JoinCard(props: {
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 "./LoginPage.tsx";
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 Page",
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 LoginPageProps = {
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
- export function LoginCard(props: LoginPageProps) {
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 "./PasswordResetPage.tsx";
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 function PasswordResetCard(props: {
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
  });
@@ -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
+ }
@@ -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/JoinPage.tsx";
4
- export * from "./account/LoginPage.tsx";
5
- export * from "./account/PasswordResetPage.tsx";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indietabletop/appkit",
3
- "version": "3.6.0-3",
3
+ "version": "4.0.0-0",
4
4
  "description": "A collection of modules used in apps built by Indie Tabletop Club",
5
5
  "private": false,
6
6
  "type": "module",
@@ -1,9 +0,0 @@
1
- import { useAppConfig } from "../AppConfig/AppConfig.tsx";
2
-
3
- export function Title(props: { children: string }) {
4
- const { appName } = useAppConfig();
5
-
6
- return (
7
- <title>{`${props.children} | ${appName} · Indie Tabletop Club`}</title>
8
- );
9
- }