@pithy-sh/ui-react-native 0.0.0-stage → 0.2.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/generatedConfig.d.ts +195 -0
  3. package/dist/generatedConfig.d.ts.map +1 -0
  4. package/dist/generatedConfig.js +323 -0
  5. package/dist/templates.d.ts +48 -0
  6. package/dist/templates.d.ts.map +1 -0
  7. package/dist/templates.js +85 -0
  8. package/dist/version.d.ts +5 -0
  9. package/dist/version.d.ts.map +1 -0
  10. package/dist/version.js +10 -0
  11. package/package.json +83 -4
  12. package/src/generatedConfig.ts +419 -0
  13. package/src/templates.ts +92 -0
  14. package/src/version.ts +13 -0
  15. package/templates/app/[...pithy].tsx +7 -0
  16. package/templates/app/_layout.tsx +31 -0
  17. package/templates/app/index.tsx +4 -0
  18. package/templates/app.config.ts +56 -0
  19. package/templates/metro-proxy.cjs +289 -0
  20. package/templates/metro.config.js +6 -0
  21. package/templates/src/payments-store.native.ts +20 -0
  22. package/templates/src/payments-store.ts +16 -0
  23. package/templates/src/payments.tsx +769 -0
  24. package/templates/src/pithy-config.generated.ts +99 -0
  25. package/templates/src/pithy-config.ts +169 -0
  26. package/templates/src/pithy-locale.tsx +90 -0
  27. package/templates/src/platform.ts +111 -0
  28. package/templates/src/route-modules.ts +76 -0
  29. package/templates/src/router.tsx +387 -0
  30. package/templates/src/routes/app/home.bare.tsx +75 -0
  31. package/templates/src/routes/app/home.tsx +55 -0
  32. package/templates/src/routes/pithy/account-deleted.tsx +69 -0
  33. package/templates/src/routes/pithy/account-suspended.tsx +70 -0
  34. package/templates/src/routes/pithy/agent-consent.tsx +260 -0
  35. package/templates/src/routes/pithy/callback.tsx +74 -0
  36. package/templates/src/routes/pithy/choose-organization.tsx +230 -0
  37. package/templates/src/routes/pithy/connected-applications.tsx +153 -0
  38. package/templates/src/routes/pithy/consent.tsx +453 -0
  39. package/templates/src/routes/pithy/invitation.tsx +239 -0
  40. package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
  41. package/templates/src/routes/pithy/otp.tsx +223 -0
  42. package/templates/src/routes/pithy/paywall.tsx +253 -0
  43. package/templates/src/routes/pithy/pricing.tsx +317 -0
  44. package/templates/src/routes/pithy/sign-in.tsx +674 -0
  45. package/templates/src/routes/pithy/subscription.tsx +240 -0
  46. package/templates/src/session.tsx +349 -0
  47. package/templates/src/theme.ts +100 -0
  48. package/templates/src/transport.ts +290 -0
  49. package/templates/src/turnstile.tsx +117 -0
  50. package/templates/src/useAppFocus.ts +20 -0
  51. package/templates/src/useStoreTransactions.ts +16 -0
  52. package/templates/src/useTurnstileWeb.ts +104 -0
  53. package/templates/tsconfig.json +10 -0
  54. package/README.md +0 -3
@@ -0,0 +1,387 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { useRead } from "@pithy-sh/ui-core/src/read";
4
+ import { Redirect as Navigate } from "@pithy-sh/ui-core/src/redirect";
5
+ import {
6
+ buildRoutes,
7
+ matchPath,
8
+ type RedirectTarget,
9
+ type RouteTable,
10
+ type ScreenRole,
11
+ screenPath,
12
+ } from "@pithy-sh/ui-core/src/routes";
13
+ import { useGlobalSearchParams, usePathname, useRouter } from "expo-router";
14
+ import {
15
+ createContext,
16
+ type ReactNode,
17
+ Suspense,
18
+ use,
19
+ useCallback,
20
+ useContext,
21
+ useMemo,
22
+ useState,
23
+ useSyncExternalStore,
24
+ } from "react";
25
+ import { type StyleProp, Text, type TextStyle, View } from "react-native";
26
+ import { parseQuery, queryString } from "./platform";
27
+ import { appRoutes, paymentsModule, pithyRoutes, sessionModule } from "./route-modules";
28
+ import { useTheme } from "./theme";
29
+
30
+ /**
31
+ * The router: which screen answers the current path, the guards in front of it, and how a screen goes elsewhere.
32
+ *
33
+ * Screens register in `route-modules.ts` and declare their own `path`, exactly as the React screens do, and the
34
+ * route table, the matching rule and the roles come from `@pithy-sh/ui-core/src/routes`. What the app supplies is
35
+ * a {@link RouterAdapter}: where it is, how to go somewhere, and when that changes. `expo-router` is the binding
36
+ * this app is written with ({@link useExpoRouterAdapter}); React Navigation, or anything else, is another adapter.
37
+ */
38
+
39
+ /** How the app navigates: the router a screen goes through, never `window`. */
40
+ export interface RouterAdapter {
41
+ /** Where the app is now: the path and its query, such as `/invitations/tok_1?resume=%2Fhome`. */
42
+ path(): string;
43
+ /** Go to `to`, a path with an optional query. `replace` swaps the current entry rather than pushing one. */
44
+ go(to: string, options: { replace: boolean }): void;
45
+ /** Call `listener` whenever {@link RouterAdapter.path} changes. Answers the unsubscribe. */
46
+ subscribe(listener: () => void): () => void;
47
+ }
48
+
49
+ /** The query parameter `app/[...pithy].tsx` captures the path in, which is not part of the query a screen reads. */
50
+ export const CATCH_ALL_PARAM = "pithy";
51
+
52
+ /**
53
+ * The adapter over `expo-router`. Its pathname and query arrive as hooks, so a navigation re-renders the caller and
54
+ * hands back a new adapter; the router reads the new path from it.
55
+ */
56
+ export function useExpoRouterAdapter(): RouterAdapter {
57
+ const pathname = usePathname();
58
+ const params = useGlobalSearchParams();
59
+ const router = useRouter();
60
+ const search = queryString(
61
+ Object.fromEntries(
62
+ Object.entries(params).flatMap(([name, value]) => {
63
+ if (name === CATCH_ALL_PARAM) return [];
64
+ const first = Array.isArray(value) ? value[0] : value;
65
+ return first === undefined ? [] : [[name, first]];
66
+ }),
67
+ ),
68
+ );
69
+ return useMemo<RouterAdapter>(
70
+ () => ({
71
+ path: () => `${pathname}${search}`,
72
+ go: (to, options) => (options.replace ? router.replace(to) : router.push(to)),
73
+ subscribe: () => () => undefined,
74
+ }),
75
+ [pathname, search, router],
76
+ );
77
+ }
78
+
79
+ /** An adapter over a path held in memory: for a test, or an app that keeps no URL at all. */
80
+ export function createMemoryAdapter(initial = "/"): RouterAdapter & { readonly history: readonly string[] } {
81
+ const history = [initial];
82
+ const listeners = new Set<() => void>();
83
+ return {
84
+ history,
85
+ path: () => history[history.length - 1] ?? "/",
86
+ go(to, options) {
87
+ if (options.replace) history[history.length - 1] = to;
88
+ else history.push(to);
89
+ for (const listener of listeners) listener();
90
+ },
91
+ subscribe(listener) {
92
+ listeners.add(listener);
93
+ return () => {
94
+ listeners.delete(listener);
95
+ };
96
+ },
97
+ };
98
+ }
99
+
100
+ interface RouterState {
101
+ readonly adapter: RouterAdapter;
102
+ /** Remount every screen, so nothing a screen held survives: what a document load does on the web. */
103
+ readonly reload: () => void;
104
+ }
105
+
106
+ const RouterContext = createContext<RouterState | null>(null);
107
+
108
+ function useRouterState(): RouterState {
109
+ const state = useContext(RouterContext);
110
+ if (state === null) throw new Error("A screen rendered outside <Router>. Mount the router once, at the app's root.");
111
+ return state;
112
+ }
113
+
114
+ let resolved: Promise<RouteTable> | null = null;
115
+
116
+ /**
117
+ * The route table, resolved once, on first use. A function rather than a module constant, because resolving it
118
+ * loads every screen, and importing this file for `Link` alone must not.
119
+ */
120
+ export function routeTable(): Promise<RouteTable> {
121
+ resolved ??= buildRoutes([...pithyRoutes, ...appRoutes]);
122
+ return resolved;
123
+ }
124
+
125
+ /** The path of the screen claiming `role`, for a link that must go where a guard would. Suspends until the table resolves. */
126
+ export function useScreenPath(role: ScreenRole): string {
127
+ return screenPath(use(routeTable()), role);
128
+ }
129
+
130
+ /** The same, `null` when no screen claims the role: a link that crosses a capability that may not be composed. */
131
+ export function useOptionalScreenPath(role: ScreenRole): string | null {
132
+ return use(routeTable()).byRole.get(role) ?? null;
133
+ }
134
+
135
+ // ── location ─────────────────────────────────────────────────────────────────
136
+
137
+ function useLocation(): string {
138
+ const { adapter } = useRouterState();
139
+ return useSyncExternalStore(adapter.subscribe, adapter.path, adapter.path);
140
+ }
141
+
142
+ function splitPath(location: string): { pathname: string; search: string } {
143
+ const at = location.indexOf("?");
144
+ return at === -1
145
+ ? { pathname: location, search: "" }
146
+ : { pathname: location.slice(0, at), search: location.slice(at) };
147
+ }
148
+
149
+ /** The current pathname. Re-renders on every navigation. */
150
+ export function usePath(): string {
151
+ return splitPath(useLocation()).pathname;
152
+ }
153
+
154
+ /** The current query string, verbatim: `"?tab=billing"`, or `""`. Re-renders on every navigation. */
155
+ export function useSearch(): string {
156
+ return splitPath(useLocation()).search;
157
+ }
158
+
159
+ /** One query parameter, decoded, or `null` when it is absent. A string from a stranger: validate what you need. */
160
+ export function useSearchParam(name: string): string | null {
161
+ return parseQuery(useSearch())[name] ?? null;
162
+ }
163
+
164
+ /**
165
+ * How a screen navigates after something a person did: `go(to)` pushes, `go(to, { replace: true })` swaps the
166
+ * current entry for a correction the reader did not make. Going where the app already is does nothing.
167
+ */
168
+ export function useGo(): (to: string, options?: { replace?: boolean }) => void {
169
+ const { adapter } = useRouterState();
170
+ return useCallback(
171
+ (to: string, options?: { replace?: boolean }) => {
172
+ if (to === adapter.path()) return;
173
+ adapter.go(to, { replace: options?.replace ?? false });
174
+ },
175
+ [adapter],
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Set or clear query parameters on the current path, leaving the rest alone. Pushes by default; pass
181
+ * `{ replace: true }` for a correction.
182
+ */
183
+ export function useUpdateSearch(): (patch: Record<string, string | null>, options?: { replace?: boolean }) => void {
184
+ const { adapter } = useRouterState();
185
+ const go = useGo();
186
+ return useCallback(
187
+ (patch: Record<string, string | null>, options?: { replace?: boolean }) => {
188
+ const { pathname, search } = splitPath(adapter.path());
189
+ const params = parseQuery(search);
190
+ for (const [name, value] of Object.entries(patch)) {
191
+ if (value === null) delete params[name];
192
+ else params[name] = value;
193
+ }
194
+ go(`${pathname}${queryString(params)}`, options);
195
+ },
196
+ [adapter, go],
197
+ );
198
+ }
199
+
200
+ /** An in-app link: text a reader presses to go to `to`. */
201
+ export function Link(props: {
202
+ to: string;
203
+ children: ReactNode;
204
+ style?: StyleProp<TextStyle>;
205
+ testID?: string;
206
+ }): ReactNode {
207
+ const go = useGo();
208
+ const theme = useTheme();
209
+ return (
210
+ <Text
211
+ role="link"
212
+ testID={props.testID}
213
+ style={[theme.type.body, { color: theme.colors.text, textDecorationLine: "underline" }, props.style]}
214
+ onPress={() => go(props.to)}
215
+ >
216
+ {props.children}
217
+ </Text>
218
+ );
219
+ }
220
+
221
+ /** What {@link Redirect} takes. */
222
+ export type RedirectProps = RedirectTarget & {
223
+ /** Replace the current entry rather than push one, for a correction the reader did not make. */
224
+ readonly replace?: boolean;
225
+ /**
226
+ * Remount every screen as it goes, so nothing the previous screens held survives: a change of account, where the
227
+ * last account's lists and headers must not linger.
228
+ */
229
+ readonly load?: boolean;
230
+ };
231
+
232
+ /**
233
+ * Go somewhere else, as one of the states a screen renders. When an answer means "not here", render this the way
234
+ * you render loading and failure. It navigates once and renders nothing.
235
+ *
236
+ * ```tsx
237
+ * if (session.value === null) return <Redirect screen="sign-in" />;
238
+ * ```
239
+ */
240
+ export function Redirect(props: RedirectProps): ReactNode {
241
+ if ("screen" in props) return <RedirectToScreen {...props} />;
242
+ return <Go to={props.to} replace={props.replace} load={props.load} />;
243
+ }
244
+
245
+ function RedirectToScreen(props: Extract<RedirectTarget, { screen: ScreenRole }> & RedirectProps): ReactNode {
246
+ const path = useScreenPath(props.screen);
247
+ return <Go to={props.search ? `${path}?${props.search}` : path} replace={props.replace} load={props.load} />;
248
+ }
249
+
250
+ function Go(props: { to: string; replace?: boolean | undefined; load?: boolean | undefined }): ReactNode {
251
+ const { adapter, reload } = useRouterState();
252
+ const load = props.load ?? false;
253
+ const go = useCallback(
254
+ (to: string, options: { replace: boolean }) => {
255
+ if (to !== adapter.path()) adapter.go(to, options);
256
+ if (load) reload();
257
+ },
258
+ [adapter, reload, load],
259
+ );
260
+ return <Navigate to={props.to} replace={props.replace} go={go} />;
261
+ }
262
+
263
+ // ── guard ────────────────────────────────────────────────────────────────────
264
+
265
+ /** Is there a session? True when the session module was not written, so an app without auth renders everything. */
266
+ async function isSignedIn(): Promise<boolean> {
267
+ if (!sessionModule) return true;
268
+ return (await (await sessionModule()).getSession()) !== null;
269
+ }
270
+
271
+ /**
272
+ * Whether there is a session: `null` while the read is in flight. For a public screen that draws something
273
+ * different for a stranger, such as a pricing screen's buy button.
274
+ */
275
+ export function useSignedIn(): boolean | null {
276
+ return useRead("signed-in", isSignedIn).value ?? null;
277
+ }
278
+
279
+ /** The line every wait renders. */
280
+ function Waiting(): ReactNode {
281
+ const t = useTranslator(EN);
282
+ const theme = useTheme();
283
+ return (
284
+ <View style={{ padding: theme.spacing.lg }}>
285
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("app/loading")}</Text>
286
+ </View>
287
+ );
288
+ }
289
+
290
+ /** Renders its children only for a signed-in visitor; everyone else is sent to the sign-in screen. */
291
+ export function RequireSignedIn(props: { children: ReactNode }): ReactNode {
292
+ const table = use(routeTable());
293
+ const signedIn = useSignedIn();
294
+ if (signedIn === true) return props.children;
295
+ if (signedIn === false) return <Redirect to={screenPath(table, "sign-in")} />;
296
+ return <Waiting />;
297
+ }
298
+
299
+ /** Does the visitor hold `key`? True when the payments module was not written, so a guard cannot lock a screen shut. */
300
+ async function holdsEntitlement(key: string): Promise<boolean> {
301
+ if (!paymentsModule) return true;
302
+ return (await paymentsModule()).holdsEntitlement(key);
303
+ }
304
+
305
+ /**
306
+ * Renders its children only for a visitor holding `entitlement`; everyone else is sent to the paywall. A courtesy,
307
+ * never a boundary: the Worker's `requireEntitlement()` is the boundary.
308
+ */
309
+ export function RequireEntitlement(props: { entitlement: string; children: ReactNode }): ReactNode {
310
+ const table = use(routeTable());
311
+ const held = useRead(`entitlement:${props.entitlement}`, () => holdsEntitlement(props.entitlement));
312
+ if (held.value === true && !held.loading) return props.children;
313
+ if (held.value === false && !held.loading) return <Redirect to={screenPath(table, "paywall")} />;
314
+ return <Waiting />;
315
+ }
316
+
317
+ /** The router's own English, under `app/` because this is the app's shell rather than a capability's screen. */
318
+ const EN = {
319
+ "app/loading": "One moment.",
320
+ "app/not_found.title": "Not here.",
321
+ "app/not_found.body": "Nothing answers {path}.",
322
+ "app/not_found.home": "Go home",
323
+ } satisfies MessageCatalog;
324
+
325
+ // ── router ───────────────────────────────────────────────────────────────────
326
+
327
+ function NotFound(props: { path: string }): ReactNode {
328
+ const t = useTranslator(EN);
329
+ const theme = useTheme();
330
+ return (
331
+ <View
332
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
333
+ >
334
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
335
+ {t.t("app/not_found.title")}
336
+ </Text>
337
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
338
+ {t.t("app/not_found.body", { path: props.path })}
339
+ </Text>
340
+ <Link to="/">{t.t("app/not_found.home")}</Link>
341
+ </View>
342
+ );
343
+ }
344
+
345
+ function Screen(): ReactNode {
346
+ const table = use(routeTable());
347
+ const path = usePath();
348
+ const match = matchPath(table.patterns, path);
349
+ const route = match ? table.byPattern.get(match.pattern) : undefined;
350
+ if (!match || !route) return <NotFound path={path} />;
351
+
352
+ const Component = route.component;
353
+ // An entitlement belongs to somebody, so asking for one implies a session: session outside, entitlement inside.
354
+ const screen = route.entitlement ? (
355
+ <RequireEntitlement entitlement={route.entitlement}>
356
+ <Component params={match.params} />
357
+ </RequireEntitlement>
358
+ ) : (
359
+ <Component params={match.params} />
360
+ );
361
+ return route.session === "required" || route.entitlement ? <RequireSignedIn>{screen}</RequireSignedIn> : screen;
362
+ }
363
+
364
+ /** The router's context without its screens: for a screen rendered on its own, in a test or a modal. */
365
+ export function RouterProvider(props: { adapter: RouterAdapter; children: ReactNode }): ReactNode {
366
+ const [generation, setGeneration] = useState(0);
367
+ const reload = useCallback(() => setGeneration((now) => now + 1), []);
368
+ const state = useMemo(() => ({ adapter: props.adapter, reload }), [props.adapter, reload]);
369
+ return (
370
+ <RouterContext value={state}>
371
+ <View key={generation} style={{ flex: 1 }}>
372
+ {props.children}
373
+ </View>
374
+ </RouterContext>
375
+ );
376
+ }
377
+
378
+ /** Mount this once, with the app's adapter. It resolves the route table, then renders the screen for the current path. */
379
+ export function Router(props: { adapter: RouterAdapter }): ReactNode {
380
+ return (
381
+ <RouterProvider adapter={props.adapter}>
382
+ <Suspense fallback={<Waiting />}>
383
+ <Screen />
384
+ </Suspense>
385
+ </RouterProvider>
386
+ );
387
+ }
@@ -0,0 +1,75 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { HEALTH_PATH } from "@pithy-sh/core/src/worker/health";
4
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
5
+ import { useRead } from "@pithy-sh/ui-core/src/read";
6
+ import type { ReactNode } from "react";
7
+ import { Text, View } from "react-native";
8
+ import { origin } from "../../pithy-config";
9
+ import { onDevice } from "../../platform";
10
+ import { useTheme } from "../../theme";
11
+
12
+ // Your screen. Pithy wrote this file once and never will again: everything under src/routes/app/ is yours. Add a
13
+ // screen by dropping a file in beside it with its own `path` export.
14
+ export const path = "/";
15
+
16
+ /** What the health route answers. Every Pithy Worker serves it. */
17
+ interface Health {
18
+ status: string;
19
+ }
20
+
21
+ function isHealth(value: unknown): value is Health {
22
+ return typeof value === "object" && value !== null && typeof (value as { status?: unknown }).status === "string";
23
+ }
24
+
25
+ /** This screen's English, baked in. `checking`, `unknown` and `unreachable` are its own words, not the Worker's. */
26
+ const EN = {
27
+ "app/home.bare.title": "It runs.",
28
+ "app/home.bare.body": "The worker says: {status}.",
29
+ "app/home.bare.checking": "checking",
30
+ "app/home.bare.unknown": "unknown",
31
+ "app/home.bare.unreachable": "unreachable",
32
+ } satisfies MessageCatalog;
33
+
34
+ /** What the health check came back with: the Worker's own word, or one of this screen's two faults. */
35
+ type Said = { reported: string } | { fault: "unknown" | "unreachable" } | null;
36
+
37
+ /**
38
+ * Ask the Worker how it is. The route is public and reads nothing about you, so it carries no credential. On a
39
+ * phone it goes to the Worker's origin; the web build is served from that origin, so its path is relative.
40
+ * `HEALTH_PATH` is the constant the Worker mounts the route from.
41
+ */
42
+ async function checkHealth(): Promise<Said> {
43
+ try {
44
+ const url = onDevice() ? `${origin}${HEALTH_PATH}` : HEALTH_PATH;
45
+ const body: unknown = await (await fetch(url)).json();
46
+ return isHealth(body) ? { reported: body.status } : { fault: "unknown" };
47
+ } catch {
48
+ return { fault: "unreachable" };
49
+ }
50
+ }
51
+
52
+ /** The word to show: the Worker's own, this screen's for a fault, or its word for still waiting. */
53
+ function said(t: Translator, health: Said): string {
54
+ if (health === null) return t.t("app/home.bare.checking");
55
+ return "reported" in health ? health.reported : t.t(`app/home.bare.${health.fault}`);
56
+ }
57
+
58
+ export default function Home(): ReactNode {
59
+ const t = useTranslator(EN);
60
+ const theme = useTheme();
61
+ const status = useRead("health", checkHealth).value ?? null;
62
+
63
+ return (
64
+ <View
65
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
66
+ >
67
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
68
+ {t.t("app/home.bare.title")}
69
+ </Text>
70
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
71
+ {t.t("app/home.bare.body", { status: said(t, status) })}
72
+ </Text>
73
+ </View>
74
+ );
75
+ }
@@ -0,0 +1,55 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import type { ReactNode } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { useSession, useSignOut } from "../../session";
6
+ import { useTheme } from "../../theme";
7
+
8
+ // Your screen. Pithy wrote this file once and never will again: everything under src/routes/app/ is yours. Add a
9
+ // screen by dropping a file in beside it with its own `path` export.
10
+ export const path = "/";
11
+
12
+ // Signed-out visitors are sent to the sign-in screen. Delete this line to make the screen public.
13
+ export const session = "required";
14
+
15
+ /** This screen's English, baked in. Keyed under `app/` because the screen is yours rather than a capability's. */
16
+ const EN = {
17
+ "app/home.title": "You're in.",
18
+ "app/home.signed_in_as": "Signed in as {email}.",
19
+ "app/home.someone": "someone",
20
+ "app/home.sign_out": "Sign out",
21
+ } satisfies MessageCatalog;
22
+
23
+ export default function Home(): ReactNode {
24
+ const t = useTranslator(EN);
25
+ const theme = useTheme();
26
+ const { session: current } = useSession();
27
+ const signOut = useSignOut();
28
+
29
+ return (
30
+ <View
31
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
32
+ >
33
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
34
+ {t.t("app/home.title")}
35
+ </Text>
36
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>
37
+ {t.t("app/home.signed_in_as", { email: current?.user.email ?? t.t("app/home.someone") })}
38
+ </Text>
39
+ <Pressable
40
+ role="button"
41
+ onPress={() => void signOut()}
42
+ style={{
43
+ borderWidth: 1,
44
+ borderColor: theme.colors.border,
45
+ borderRadius: theme.radius,
46
+ paddingVertical: theme.spacing.sm,
47
+ paddingHorizontal: theme.spacing.md,
48
+ alignSelf: "flex-start",
49
+ }}
50
+ >
51
+ <Text style={[theme.type.button, { color: theme.colors.text }]}>{t.t("app/home.sign_out")}</Text>
52
+ </Pressable>
53
+ </View>
54
+ );
55
+ }
@@ -0,0 +1,69 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { type ReactNode, useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { useSignOut } from "../../session";
6
+ import { useTheme } from "../../theme";
7
+
8
+ export const path = "/account-deleted";
9
+
10
+ // Where a deleted account is sent: the sign-in and callback screens route here on `auth/account_deleted`,
11
+ // by this role rather than by the path above, so renaming the path moves both.
12
+ export const role = "account-deleted";
13
+
14
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
15
+ //
16
+ // **The purge date is in the email, not here.** The deletion mailed it to the address on the account, and a date
17
+ // read off this screen's link would be one anybody could write. So the screen says what is true for every deleted
18
+ // account: it cannot sign in, it can still come back for a while, and the email says until when.
19
+
20
+ /** This screen's English, baked in: the only catalog that survives being copied into your repository. */
21
+ const EN = {
22
+ "auth/deleted.title": "Your account is deleted.",
23
+ "auth/deleted.body": "You cannot sign in. Its data is erased when the deletion window closes.",
24
+ "auth/deleted.detail": "Until then it can be restored. We emailed the date to the address on your account.",
25
+ "auth/deleted.contact": "To restore it, reply to that email before then.",
26
+ "auth/deleted.sign_out": "Sign out",
27
+ } satisfies MessageCatalog;
28
+
29
+ export default function AccountDeleted(): ReactNode {
30
+ const t = useTranslator(EN);
31
+ const theme = useTheme();
32
+ const signOut = useSignOut();
33
+ const [busy, setBusy] = useState(false);
34
+
35
+ return (
36
+ <View
37
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
38
+ >
39
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
40
+ {t.t("auth/deleted.title")}
41
+ </Text>
42
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>{t.t("auth/deleted.body")}</Text>
43
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("auth/deleted.detail")}</Text>
44
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("auth/deleted.contact")}</Text>
45
+ {/* A session may still be on this device; ending it is what lets somebody else sign in here. */}
46
+ <Pressable
47
+ role="button"
48
+ disabled={busy}
49
+ aria-disabled={busy}
50
+ onPress={() => {
51
+ if (busy) return;
52
+ setBusy(true);
53
+ void signOut();
54
+ }}
55
+ style={{
56
+ borderWidth: 1,
57
+ borderColor: theme.colors.border,
58
+ borderRadius: theme.radius,
59
+ paddingVertical: theme.spacing.sm,
60
+ paddingHorizontal: theme.spacing.md,
61
+ alignSelf: "flex-start",
62
+ opacity: busy ? 0.5 : 1,
63
+ }}
64
+ >
65
+ <Text style={[theme.type.button, { color: theme.colors.text }]}>{t.t("auth/deleted.sign_out")}</Text>
66
+ </Pressable>
67
+ </View>
68
+ );
69
+ }
@@ -0,0 +1,70 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { type ReactNode, useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { useSignOut } from "../../session";
6
+ import { useTheme } from "../../theme";
7
+
8
+ export const path = "/account-suspended";
9
+
10
+ // Where a suspended account is sent: the sign-in and callback screens route here on `auth/account_suspended`,
11
+ // by this role rather than by the path above, so renaming the path moves both.
12
+ export const role = "account-suspended";
13
+
14
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
15
+ //
16
+ // **The reason is not on this screen, and that is deliberate.** The operator's reason reaches the person in the
17
+ // email the suspension sent, and in the `message` of every refusal their app receives. It does not ride in this
18
+ // screen's link: a sentence read off a deep link is a sentence anybody can write. So the screen says what
19
+ // happened, what it means, and where the details are.
20
+
21
+ /** This screen's English, baked in: the only catalog that survives being copied into your repository. */
22
+ const EN = {
23
+ "auth/suspended.title": "Your account is suspended.",
24
+ "auth/suspended.body": "You cannot sign in or use this app while it is suspended.",
25
+ "auth/suspended.detail": "We emailed the reason to the address on your account.",
26
+ "auth/suspended.contact": "If you think this is a mistake, reply to that email.",
27
+ "auth/suspended.sign_out": "Sign out",
28
+ } satisfies MessageCatalog;
29
+
30
+ export default function AccountSuspended(): ReactNode {
31
+ const t = useTranslator(EN);
32
+ const theme = useTheme();
33
+ const signOut = useSignOut();
34
+ const [busy, setBusy] = useState(false);
35
+
36
+ return (
37
+ <View
38
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
39
+ >
40
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
41
+ {t.t("auth/suspended.title")}
42
+ </Text>
43
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>{t.t("auth/suspended.body")}</Text>
44
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("auth/suspended.detail")}</Text>
45
+ <Text style={[theme.type.body, { color: theme.colors.muted }]}>{t.t("auth/suspended.contact")}</Text>
46
+ {/* A session may still be on this device; ending it is what lets somebody else sign in here. */}
47
+ <Pressable
48
+ role="button"
49
+ disabled={busy}
50
+ aria-disabled={busy}
51
+ onPress={() => {
52
+ if (busy) return;
53
+ setBusy(true);
54
+ void signOut();
55
+ }}
56
+ style={{
57
+ borderWidth: 1,
58
+ borderColor: theme.colors.border,
59
+ borderRadius: theme.radius,
60
+ paddingVertical: theme.spacing.sm,
61
+ paddingHorizontal: theme.spacing.md,
62
+ alignSelf: "flex-start",
63
+ opacity: busy ? 0.5 : 1,
64
+ }}
65
+ >
66
+ <Text style={[theme.type.button, { color: theme.colors.text }]}>{t.t("auth/suspended.sign_out")}</Text>
67
+ </Pressable>
68
+ </View>
69
+ );
70
+ }