@pithy-sh/ui-react-native 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/dist/generatedConfig.d.ts +195 -0
- package/dist/generatedConfig.d.ts.map +1 -0
- package/dist/generatedConfig.js +323 -0
- package/dist/templates.d.ts +48 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +85 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/package.json +83 -4
- package/src/generatedConfig.ts +419 -0
- package/src/templates.ts +92 -0
- package/src/version.ts +13 -0
- package/templates/app/[...pithy].tsx +7 -0
- package/templates/app/_layout.tsx +31 -0
- package/templates/app/index.tsx +4 -0
- package/templates/app.config.ts +56 -0
- package/templates/metro-proxy.cjs +289 -0
- package/templates/metro.config.js +6 -0
- package/templates/src/payments-store.native.ts +20 -0
- package/templates/src/payments-store.ts +16 -0
- package/templates/src/payments.tsx +769 -0
- package/templates/src/pithy-config.generated.ts +99 -0
- package/templates/src/pithy-config.ts +169 -0
- package/templates/src/pithy-locale.tsx +90 -0
- package/templates/src/platform.ts +111 -0
- package/templates/src/route-modules.ts +76 -0
- package/templates/src/router.tsx +387 -0
- package/templates/src/routes/app/home.bare.tsx +75 -0
- package/templates/src/routes/app/home.tsx +55 -0
- package/templates/src/routes/pithy/account-deleted.tsx +69 -0
- package/templates/src/routes/pithy/account-suspended.tsx +70 -0
- package/templates/src/routes/pithy/agent-consent.tsx +260 -0
- package/templates/src/routes/pithy/callback.tsx +74 -0
- package/templates/src/routes/pithy/choose-organization.tsx +230 -0
- package/templates/src/routes/pithy/connected-applications.tsx +153 -0
- package/templates/src/routes/pithy/consent.tsx +453 -0
- package/templates/src/routes/pithy/invitation.tsx +239 -0
- package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
- package/templates/src/routes/pithy/otp.tsx +196 -0
- package/templates/src/routes/pithy/paywall.tsx +253 -0
- package/templates/src/routes/pithy/pricing.tsx +317 -0
- package/templates/src/routes/pithy/sign-in.tsx +673 -0
- package/templates/src/routes/pithy/subscription.tsx +240 -0
- package/templates/src/session.tsx +290 -0
- package/templates/src/theme.ts +100 -0
- package/templates/src/transport.ts +290 -0
- package/templates/src/turnstile.tsx +117 -0
- package/templates/src/useAppFocus.ts +20 -0
- package/templates/src/useStoreTransactions.ts +16 -0
- package/templates/src/useTurnstileWeb.ts +104 -0
- package/templates/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Written from apps/__PITHY_WORKER__'s composed config. Pithy's `ui sync --app` rewrites it when that config
|
|
2
|
+
// changes, and keeps the fields below that are yours.
|
|
3
|
+
//
|
|
4
|
+
// Yours to edit, and kept by a sync: `appScheme`, `universalLinkOrigin`, `google` and `rails`.
|
|
5
|
+
//
|
|
6
|
+
// `rails.ios`, `rails.android` and `rails.web` name the rail each platform sells through: "apple", "google",
|
|
7
|
+
// "stripe", "lemonSqueezy", "paddle", or "none" to show plans and sell nothing. Change one here, or pass
|
|
8
|
+
// `--ios-rail`, `--android-rail` or `--web-rail` to `ui sync --app`. A rail the Worker does not enable is
|
|
9
|
+
// refused. A web rail on a phone opens checkout in the system browser.
|
|
10
|
+
//
|
|
11
|
+
// `origins` holds where the Worker answers in each environment, and a build calls the one
|
|
12
|
+
// `EXPO_PUBLIC_PITHY_ENV` names: dev, staging or prod, dev when it is unset. An origin is null when Pithy
|
|
13
|
+
// could not find it: a Worker that answers only on workers.dev, or one that declares no domain. Set
|
|
14
|
+
// `origins.staging` or `origins.prod` by hand then, and a sync keeps it until the Worker declares one.
|
|
15
|
+
// A dev origin that is not this machine's loopback, such as your network address for a device, is kept too.
|
|
16
|
+
//
|
|
17
|
+
// `auth`, `i18n`, `payments`, `turnstile` and `consent` hold each capability's projection in each environment
|
|
18
|
+
// `origins` names, and a build reads the one for its environment: a sitekey or a catalog can differ between them.
|
|
19
|
+
//
|
|
20
|
+
// Everything else is the Worker's. Change its pithy.config.ts and run `ui sync --app` again.
|
|
21
|
+
// The value stays a plain literal: strings, numbers, true, false, null, arrays and objects. A sync reads it
|
|
22
|
+
// back without running it, so it holds no expression, no variable and no import.
|
|
23
|
+
import type { GeneratedPithyConfig } from "./pithy-config";
|
|
24
|
+
|
|
25
|
+
const config: GeneratedPithyConfig = {
|
|
26
|
+
origins: {
|
|
27
|
+
dev: "http://localhost:8787",
|
|
28
|
+
staging: null,
|
|
29
|
+
prod: null,
|
|
30
|
+
},
|
|
31
|
+
appScheme: "pithyapp",
|
|
32
|
+
universalLinkOrigin: null,
|
|
33
|
+
rails: {
|
|
34
|
+
ios: "apple",
|
|
35
|
+
android: "google",
|
|
36
|
+
web: "none",
|
|
37
|
+
},
|
|
38
|
+
google: {
|
|
39
|
+
webClientId: null,
|
|
40
|
+
iosClientId: null,
|
|
41
|
+
},
|
|
42
|
+
auth: {
|
|
43
|
+
dev: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
},
|
|
46
|
+
staging: {
|
|
47
|
+
enabled: false,
|
|
48
|
+
},
|
|
49
|
+
prod: {
|
|
50
|
+
enabled: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
i18n: {
|
|
54
|
+
dev: {
|
|
55
|
+
enabled: false,
|
|
56
|
+
},
|
|
57
|
+
staging: {
|
|
58
|
+
enabled: false,
|
|
59
|
+
},
|
|
60
|
+
prod: {
|
|
61
|
+
enabled: false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
payments: {
|
|
65
|
+
dev: {
|
|
66
|
+
enabled: false,
|
|
67
|
+
},
|
|
68
|
+
staging: {
|
|
69
|
+
enabled: false,
|
|
70
|
+
},
|
|
71
|
+
prod: {
|
|
72
|
+
enabled: false,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
turnstile: {
|
|
76
|
+
dev: {
|
|
77
|
+
enabled: false,
|
|
78
|
+
},
|
|
79
|
+
staging: {
|
|
80
|
+
enabled: false,
|
|
81
|
+
},
|
|
82
|
+
prod: {
|
|
83
|
+
enabled: false,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
consent: {
|
|
87
|
+
dev: {
|
|
88
|
+
enabled: false,
|
|
89
|
+
},
|
|
90
|
+
staging: {
|
|
91
|
+
enabled: false,
|
|
92
|
+
},
|
|
93
|
+
prod: {
|
|
94
|
+
enabled: false,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default config;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { AuthClientProjection } from "@pithy-sh/auth/src/client/projection";
|
|
2
|
+
import type { ConsentClientProjection } from "@pithy-sh/consent/src/client/projection";
|
|
3
|
+
import type { I18nClientProjection } from "@pithy-sh/i18n/src/client/projection";
|
|
4
|
+
import type { PaymentsClientProjection } from "@pithy-sh/payments/src/client/projection";
|
|
5
|
+
import type { PaymentsPlatformRails } from "@pithy-sh/payments/src/client/railChoice";
|
|
6
|
+
import type { TurnstileClientProjection } from "@pithy-sh/turnstile/src/client/projection";
|
|
7
|
+
import {
|
|
8
|
+
AUTH_DISABLED,
|
|
9
|
+
CONSENT_DISABLED,
|
|
10
|
+
I18N_DISABLED,
|
|
11
|
+
PAYMENTS_DISABLED,
|
|
12
|
+
TURNSTILE_DISABLED,
|
|
13
|
+
} from "@pithy-sh/ui-core/src/config";
|
|
14
|
+
import { Platform } from "react-native";
|
|
15
|
+
import generated from "./pithy-config.generated";
|
|
16
|
+
|
|
17
|
+
/** The environments a build can name. */
|
|
18
|
+
export type AppEnvironment = "dev" | "staging" | "prod";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One capability's projection in each environment, keyed like `origins`: `dev` always, `staging` and `prod` when the
|
|
22
|
+
* project deploys there. A sitekey or a catalog can differ between environments.
|
|
23
|
+
*/
|
|
24
|
+
export interface PerEnvironment<T> {
|
|
25
|
+
readonly dev: T;
|
|
26
|
+
readonly staging?: T;
|
|
27
|
+
readonly prod?: T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What `pithy-config.generated.ts` holds: the Worker this app calls, the composed capabilities' projections in each
|
|
32
|
+
* environment, and the choices that are yours — the app's scheme, its universal-link origin, its Google client ids,
|
|
33
|
+
* and the rail each platform sells through. `pithy ui sync` rewrites the projections and keeps your choices.
|
|
34
|
+
*/
|
|
35
|
+
export interface GeneratedPithyConfig {
|
|
36
|
+
/**
|
|
37
|
+
* Where the Worker answers in each environment. A build calls the one `EXPO_PUBLIC_PITHY_ENV` names, `dev` when it
|
|
38
|
+
* is unset. `null` is an origin Pithy could not find; set it by hand. An environment the project does not deploy
|
|
39
|
+
* to is absent.
|
|
40
|
+
*/
|
|
41
|
+
readonly origins: {
|
|
42
|
+
readonly dev: string;
|
|
43
|
+
readonly staging?: string | null;
|
|
44
|
+
readonly prod?: string | null;
|
|
45
|
+
};
|
|
46
|
+
/** The app's own URL scheme. A sign-in returns to a link on it. */
|
|
47
|
+
readonly appScheme: string;
|
|
48
|
+
/**
|
|
49
|
+
* The https origin whose links open the app, or `null` for none. A checkout in the system browser returns here,
|
|
50
|
+
* and so does a sign-in when it is set; with none, a sign-in returns on the app's scheme.
|
|
51
|
+
*/
|
|
52
|
+
readonly universalLinkOrigin: string | null;
|
|
53
|
+
/** The rail each platform sells through. */
|
|
54
|
+
readonly rails: PaymentsPlatformRails;
|
|
55
|
+
/** The Google OAuth client ids native Google sign-in needs. Null turns it off. */
|
|
56
|
+
readonly google: { readonly webClientId: string | null; readonly iosClientId: string | null };
|
|
57
|
+
readonly auth: PerEnvironment<AuthClientProjection>;
|
|
58
|
+
readonly i18n: PerEnvironment<I18nClientProjection>;
|
|
59
|
+
readonly payments: PerEnvironment<PaymentsClientProjection>;
|
|
60
|
+
readonly turnstile: PerEnvironment<TurnstileClientProjection>;
|
|
61
|
+
readonly consent: PerEnvironment<ConsentClientProjection>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const config: GeneratedPithyConfig = generated;
|
|
65
|
+
|
|
66
|
+
/** Expo inlines `EXPO_PUBLIC_*` variables into the bundle when it builds, so this is read once, at build time. */
|
|
67
|
+
declare const process: { readonly env: { readonly EXPO_PUBLIC_PITHY_ENV?: string } };
|
|
68
|
+
|
|
69
|
+
/** The environment a build names in `EXPO_PUBLIC_PITHY_ENV`: `dev` when it names none. */
|
|
70
|
+
export function appEnvironment(named: string | undefined): AppEnvironment {
|
|
71
|
+
if (named === undefined || named === "") return "dev";
|
|
72
|
+
if (named === "dev" || named === "staging" || named === "prod") return named;
|
|
73
|
+
throw new Error(`EXPO_PUBLIC_PITHY_ENV is "${named}". Build with dev, staging or prod.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The environment this build calls. */
|
|
77
|
+
export const environment: AppEnvironment = appEnvironment(process.env.EXPO_PUBLIC_PITHY_ENV);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The projection a build for `environment` reads. A file with none for it was written before the project deployed
|
|
81
|
+
* there: `pithy ui sync --app` writes one for each environment the project deploys to.
|
|
82
|
+
*/
|
|
83
|
+
export function projectionFor<T>(projections: PerEnvironment<T>, field: string, environment: AppEnvironment): T {
|
|
84
|
+
const found = projections[environment];
|
|
85
|
+
if (found === undefined) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`No ${field}.${environment} in src/pithy-config.generated.ts. Run pithy ui sync --app once the project deploys to ${environment}.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const auth = projectionFor(config.auth, "auth", environment);
|
|
94
|
+
const turnstile = projectionFor(config.turnstile, "turnstile", environment);
|
|
95
|
+
const payments = projectionFor(config.payments, "payments", environment);
|
|
96
|
+
const i18n = projectionFor(config.i18n, "i18n", environment);
|
|
97
|
+
const consent = projectionFor(config.consent, "consent", environment);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The composed capabilities' client-safe config for this build's environment, narrowed once for the whole app.
|
|
101
|
+
*
|
|
102
|
+
* Each projection is a union on `enabled`: a capability the Worker does not compose projects `{ enabled: false }`
|
|
103
|
+
* and nothing else. Narrowing here, once, to the projection or `@pithy-sh/ui-core`'s defaults lets a screen read a
|
|
104
|
+
* field without a guard. The defaults keep the screens compiling when a capability is removed after the scaffold.
|
|
105
|
+
*/
|
|
106
|
+
export const authConfig = auth.enabled ? auth : AUTH_DISABLED;
|
|
107
|
+
|
|
108
|
+
/** Turnstile's projection, narrowed the same way. `enabled` is false unless a login widget resolved. */
|
|
109
|
+
export const turnstileConfig = turnstile.enabled ? turnstile : TURNSTILE_DISABLED;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Payments' projection, narrowed the same way. Only the client-safe half of the catalog is here; every store
|
|
113
|
+
* credential stays in the Worker's secrets store.
|
|
114
|
+
*/
|
|
115
|
+
export const paymentsConfig = payments.enabled ? payments : PAYMENTS_DISABLED;
|
|
116
|
+
|
|
117
|
+
/** The i18n projection, narrowed the same way. Locale metadata only; catalogs load per locale. */
|
|
118
|
+
export const i18nConfig = i18n.enabled ? i18n : I18N_DISABLED;
|
|
119
|
+
|
|
120
|
+
/** The i18n projection as the Worker composed it, for the hook that negotiates from it. */
|
|
121
|
+
export const i18nProjection: I18nClientProjection = i18n;
|
|
122
|
+
|
|
123
|
+
/** Consent's projection, narrowed the same way. The consent screen and the transport call under its base path. */
|
|
124
|
+
export const consentConfig = consent.enabled ? consent : CONSENT_DISABLED;
|
|
125
|
+
|
|
126
|
+
/** A host that is this machine. */
|
|
127
|
+
const LOOPBACK = /^(https?:\/\/)(localhost|127\.0\.0\.1)(:\d+)?$/;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The origin a build calls. An Android emulator is a machine of its own, and reaches this one's loopback at
|
|
131
|
+
* `10.0.2.2`. A device on your network reaches neither: set `origins.dev` to this machine's network address.
|
|
132
|
+
* The web build calls relative paths from the Worker's own origin, so it needs none: `""` when none is set.
|
|
133
|
+
*/
|
|
134
|
+
export function originFor(
|
|
135
|
+
origins: GeneratedPithyConfig["origins"],
|
|
136
|
+
environment: AppEnvironment,
|
|
137
|
+
platform: string,
|
|
138
|
+
): string {
|
|
139
|
+
const found = origins[environment];
|
|
140
|
+
if (found === undefined || found === null) {
|
|
141
|
+
if (platform === "web") return "";
|
|
142
|
+
throw new Error(
|
|
143
|
+
`No origin for ${environment}. Set origins.${environment} in src/pithy-config.generated.ts to the Worker's address there.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
// A phone sends its bearer credential to this origin, so a deployed one is https.
|
|
147
|
+
if (platform !== "web" && environment !== "dev" && !found.startsWith("https://")) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`origins.${environment} is ${found}. Set it to the Worker's https address in src/pithy-config.generated.ts.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (platform !== "android") return found;
|
|
153
|
+
return found.replace(LOOPBACK, (_match, scheme: string, _host: string, port = "") => `${scheme}10.0.2.2${port}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The Worker's absolute origin in that environment. Every request on a phone goes here. */
|
|
157
|
+
export const origin: string = originFor(config.origins, environment, Platform.OS);
|
|
158
|
+
|
|
159
|
+
/** The app's own URL scheme. */
|
|
160
|
+
export const appScheme: string = config.appScheme;
|
|
161
|
+
|
|
162
|
+
/** The https origin whose links open the app, or `null` when the app claims none. */
|
|
163
|
+
export const universalLinkOrigin: string | null = config.universalLinkOrigin;
|
|
164
|
+
|
|
165
|
+
/** The rail each platform sells through. */
|
|
166
|
+
export const platformRails: PaymentsPlatformRails = config.rails;
|
|
167
|
+
|
|
168
|
+
/** The Google OAuth client ids native Google sign-in needs. */
|
|
169
|
+
export const googleClientIds: GeneratedPithyConfig["google"] = config.google;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
|
|
2
|
+
import { nativeLocaleEnvironment } from "@pithy-sh/i18n/src/native/environment";
|
|
3
|
+
import { TranslatorProvider, useTranslator } from "@pithy-sh/i18n/src/react/translator";
|
|
4
|
+
import { useNegotiatedLocale } from "@pithy-sh/i18n/src/react/useNegotiatedLocale";
|
|
5
|
+
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
|
6
|
+
import { Text, View } from "react-native";
|
|
7
|
+
import { i18nProjection } from "./pithy-config";
|
|
8
|
+
import { onDevice } from "./platform";
|
|
9
|
+
import { useTheme } from "./theme";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The language the app is in, negotiated once and rendered by every screen under it.
|
|
13
|
+
*
|
|
14
|
+
* On a phone the starting point is the device's own languages, through `expo-localization`, and the app follows a
|
|
15
|
+
* change while it runs; a choice is remembered in AsyncStorage, since a language is not a secret. Direction goes
|
|
16
|
+
* through `I18nManager`, which React Native applies only after a reload, so when the language in force lays out
|
|
17
|
+
* the other way the app says so rather than flip half a layout. On the web it is the browser's languages and the
|
|
18
|
+
* document's `lang` and `dir`, exactly as the React screens negotiate.
|
|
19
|
+
*
|
|
20
|
+
* With no i18n capability composed it renders its children untouched, and every screen reads the English it was
|
|
21
|
+
* scaffolded with.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Which platform's signals and memory the negotiation uses. A module constant: the hook needs the same one every render. */
|
|
25
|
+
const environment = onDevice() ? nativeLocaleEnvironment : undefined;
|
|
26
|
+
|
|
27
|
+
/** The language in force and how to choose another, for a language picker of your own. */
|
|
28
|
+
export interface LocaleChoice {
|
|
29
|
+
/** The catalog locale in force. */
|
|
30
|
+
readonly locale: string;
|
|
31
|
+
/** Choose a language the project supports. Remembered on this device. */
|
|
32
|
+
readonly choose: (locale: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LocaleChoiceContext = createContext<LocaleChoice>({ locale: "en", choose: () => undefined });
|
|
36
|
+
|
|
37
|
+
/** The language in force and how to choose another. */
|
|
38
|
+
export function useLocaleChoice(): LocaleChoice {
|
|
39
|
+
return useContext(LocaleChoiceContext);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What {@link PithyLocale} takes: the app, and your own catalogs if you have any. */
|
|
43
|
+
export interface PithyLocaleProps {
|
|
44
|
+
readonly children: ReactNode;
|
|
45
|
+
/**
|
|
46
|
+
* Your own catalogs, keyed by locale: the top layer, above the kit's translation and each screen's English. A
|
|
47
|
+
* sentence you want said differently goes here, and so do the words for a language the kit writes nothing in.
|
|
48
|
+
*/
|
|
49
|
+
readonly messages?: Readonly<Record<string, MessageCatalog | undefined>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EN = {
|
|
53
|
+
"app/locale.reload_required": "Restart the app to finish changing the language.",
|
|
54
|
+
} satisfies MessageCatalog;
|
|
55
|
+
|
|
56
|
+
/** The line a reader sees when the language in force needs a restart to lay out the right way round. */
|
|
57
|
+
function ReloadNotice(): ReactNode {
|
|
58
|
+
const t = useTranslator(EN);
|
|
59
|
+
const theme = useTheme();
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
role="alert"
|
|
63
|
+
style={{
|
|
64
|
+
padding: theme.spacing.md,
|
|
65
|
+
backgroundColor: theme.colors.surface,
|
|
66
|
+
borderBottomWidth: 1,
|
|
67
|
+
borderBottomColor: theme.colors.border,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<Text style={[theme.type.caption, { color: theme.colors.text }]}>{t.t("app/locale.reload_required")}</Text>
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mount the negotiated locale over the app. Until the catalog lands, screens render their own English. */
|
|
76
|
+
export function PithyLocale({ children, messages }: PithyLocaleProps): ReactNode {
|
|
77
|
+
const negotiated = useNegotiatedLocale(i18nProjection, { messages, environment });
|
|
78
|
+
const choice = useMemo(
|
|
79
|
+
() => ({ locale: negotiated.locale, choose: negotiated.choose }),
|
|
80
|
+
[negotiated.locale, negotiated.choose],
|
|
81
|
+
);
|
|
82
|
+
const body = (
|
|
83
|
+
<LocaleChoiceContext value={choice}>
|
|
84
|
+
{negotiated.reloadRequired ? <ReloadNotice /> : null}
|
|
85
|
+
{children}
|
|
86
|
+
</LocaleChoiceContext>
|
|
87
|
+
);
|
|
88
|
+
if (negotiated.source === null) return body;
|
|
89
|
+
return <TranslatorProvider value={negotiated.source}>{body}</TranslatorProvider>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as Linking from "expo-linking";
|
|
2
|
+
import * as WebBrowser from "expo-web-browser";
|
|
3
|
+
import { Platform } from "react-native";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The few things a screen needs from the platform that React Native does not answer the same way everywhere.
|
|
7
|
+
*
|
|
8
|
+
* **Query strings and links are read by hand here, not with `URL` or `URLSearchParams`.** React Native ships both
|
|
9
|
+
* names, but its `URL` does not parse and its `URLSearchParams` cannot read a value back, so code that works in a
|
|
10
|
+
* browser and in a test throws on a phone. Everything in this file is plain string work that answers the same on
|
|
11
|
+
* all three platforms.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The platform a device registers as with the Worker. Anything React Native names other than a phone is the web. */
|
|
15
|
+
export function devicePlatform(): "ios" | "android" | "web" {
|
|
16
|
+
if (Platform.OS === "ios" || Platform.OS === "android") return Platform.OS;
|
|
17
|
+
return "web";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True on a phone, where there is no page origin and no cookie: the session is a bearer credential. */
|
|
21
|
+
export function onDevice(): boolean {
|
|
22
|
+
return devicePlatform() !== "web";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One percent-encoded query component, decoded; a malformed escape keeps its raw text rather than throwing. */
|
|
26
|
+
function decodeComponent(value: string): string {
|
|
27
|
+
const spaced = value.replace(/\+/g, " ");
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(spaced);
|
|
30
|
+
} catch {
|
|
31
|
+
return spaced;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A query string as name to value: `"?email=ada%40example.com"` gives `{ email: "ada@example.com" }`. The leading
|
|
37
|
+
* `?` is optional. The first value of a repeated name wins, as `URLSearchParams.get` answers.
|
|
38
|
+
*/
|
|
39
|
+
export function parseQuery(search: string): Record<string, string> {
|
|
40
|
+
const params: Record<string, string> = {};
|
|
41
|
+
const query = search.startsWith("?") ? search.slice(1) : search;
|
|
42
|
+
for (const pair of query.split("&")) {
|
|
43
|
+
if (pair === "") continue;
|
|
44
|
+
const split = pair.indexOf("=");
|
|
45
|
+
const name = decodeComponent(split === -1 ? pair : pair.slice(0, split));
|
|
46
|
+
const value = split === -1 ? "" : decodeComponent(pair.slice(split + 1));
|
|
47
|
+
if (!(name in params)) params[name] = value;
|
|
48
|
+
}
|
|
49
|
+
return params;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One parameter of a query string, decoded, or `null` when the name is absent. */
|
|
53
|
+
export function searchParam(search: string, name: string): string | null {
|
|
54
|
+
return parseQuery(search)[name] ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A query string from name to value, with its `?`, or the empty string when there is nothing to write. */
|
|
58
|
+
export function queryString(params: Readonly<Record<string, string>>): string {
|
|
59
|
+
const pairs = Object.entries(params).map(
|
|
60
|
+
([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
|
61
|
+
);
|
|
62
|
+
return pairs.length === 0 ? "" : `?${pairs.join("&")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A link split into its parts. `search` keeps its `?`, and is empty when there is none; the fragment is dropped. */
|
|
66
|
+
export interface ParsedLink {
|
|
67
|
+
/** The scheme, lower-cased: `https`, or the app's own. */
|
|
68
|
+
readonly scheme: string;
|
|
69
|
+
/** What follows `//` up to the path: a host, or the first word of a custom-scheme link. */
|
|
70
|
+
readonly host: string;
|
|
71
|
+
/** The path, starting with `/`, or empty. */
|
|
72
|
+
readonly path: string;
|
|
73
|
+
/** The query, starting with `?`, or empty. */
|
|
74
|
+
readonly search: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Split `myapp://callback?code=…` or `https://app.example.com/return?session=…` into its parts, or answer `null`
|
|
79
|
+
* for anything that is not `scheme://…`.
|
|
80
|
+
*/
|
|
81
|
+
export function parseLink(link: string): ParsedLink | null {
|
|
82
|
+
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?/.exec(link);
|
|
83
|
+
if (!match) return null;
|
|
84
|
+
return {
|
|
85
|
+
scheme: (match[1] ?? "").toLowerCase(),
|
|
86
|
+
host: match[2] ?? "",
|
|
87
|
+
path: match[3] ?? "",
|
|
88
|
+
search: match[4] ?? "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The app's own deep link to `path`, in the scheme this build answers to. A development build has a scheme of its
|
|
94
|
+
* own, so the link is asked of `expo-linking` rather than written from the configured scheme.
|
|
95
|
+
*/
|
|
96
|
+
export function appLink(path: string): string {
|
|
97
|
+
return Linking.createURL(path);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Open an http or https page outside the app: the system browser on a phone, where a checkout or a store page
|
|
102
|
+
* must not run inside the app's own view, and a new tab on the web. Any other scheme is refused, so an address a
|
|
103
|
+
* response carried cannot run script or open another app. `false` when it was refused.
|
|
104
|
+
*/
|
|
105
|
+
export async function openExternal(url: string): Promise<boolean> {
|
|
106
|
+
const scheme = parseLink(url)?.scheme;
|
|
107
|
+
if (scheme !== "https" && scheme !== "http") return false;
|
|
108
|
+
if (onDevice()) await WebBrowser.openBrowserAsync(url);
|
|
109
|
+
else await Linking.openURL(url);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RouteModule } from "@pithy-sh/ui-core/src/routes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* The contexts below ARE the route registration. Do not edit them.
|
|
6
|
+
*
|
|
7
|
+
* src/routes/pithy/ Pithy's screens. Pithy writes only here, and only files that do not exist.
|
|
8
|
+
* src/routes/app/ Yours. Pithy never writes here.
|
|
9
|
+
*
|
|
10
|
+
* Dropping a file in registers it: no manifest, no command to re-run. Each module declares its own path, so
|
|
11
|
+
* filenames encode no routing rules:
|
|
12
|
+
*
|
|
13
|
+
* export const path = "/sign-in";
|
|
14
|
+
* export default SignIn;
|
|
15
|
+
*
|
|
16
|
+
* `app/` wins on a conflict: override a Pithy screen by putting your own file at the same path.
|
|
17
|
+
*
|
|
18
|
+
* `require.context` is Metro's directory import, the one `expo-router` itself registers screens with. Its
|
|
19
|
+
* arguments must be literals, because Metro reads them while it bundles. Both contexts read `src/routes/`, which
|
|
20
|
+
* your home screen keeps on disk, and pick their folder by filter: a directory Metro cannot find fails the
|
|
21
|
+
* bundle, and an app with no Pithy screens has no `src/routes/pithy/`. The filter also keeps co-located tests
|
|
22
|
+
* out of the bundle: `home.test.tsx` beside `home.tsx` would otherwise ship, fixtures and stub tokens included.
|
|
23
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A directory Metro has read: its file names, and a module by name. */
|
|
27
|
+
interface ModuleContext {
|
|
28
|
+
keys(): string[];
|
|
29
|
+
(id: string): unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare const require: {
|
|
33
|
+
context(path: string, recursive: boolean, filter: RegExp, mode: "sync"): ModuleContext;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** One loader per file in `context`, in a stable order. */
|
|
37
|
+
export function contextLoaders<T>(context: ModuleContext): (() => Promise<T>)[] {
|
|
38
|
+
return context
|
|
39
|
+
.keys()
|
|
40
|
+
.sort()
|
|
41
|
+
.map((key) => async () => context(key) as T);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pithy's screens. */
|
|
45
|
+
export const pithyRoutes = contextLoaders<RouteModule>(
|
|
46
|
+
require.context("./routes", true, /^\.\/pithy\/(?!.*\.(?:test|spec)\.tsx$).*\.tsx$/, "sync"),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** Your screens. Registered after Pithy's, so one at the same path takes it over. */
|
|
50
|
+
export const appRoutes = contextLoaders<RouteModule>(
|
|
51
|
+
require.context("./routes", true, /^\.\/app\/(?!.*\.(?:test|spec)\.tsx$).*\.tsx$/, "sync"),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The session module, when the auth screens were written: the router's sign-in guard asks it. Read through a
|
|
56
|
+
* context rather than imported, so this file is the same with or without it.
|
|
57
|
+
*/
|
|
58
|
+
export const sessionModule: (() => Promise<{ getSession: () => Promise<unknown> }>) | undefined = contextLoaders<{
|
|
59
|
+
getSession: () => Promise<unknown>;
|
|
60
|
+
}>(require.context("./", false, /^\.\/session\.tsx$/, "sync"))[0];
|
|
61
|
+
|
|
62
|
+
/** The payments module, when the payments screens were written: the router's entitlement guard asks it. */
|
|
63
|
+
export const paymentsModule: (() => Promise<{ holdsEntitlement: (key: string) => Promise<boolean> }>) | undefined =
|
|
64
|
+
contextLoaders<{ holdsEntitlement: (key: string) => Promise<boolean> }>(
|
|
65
|
+
require.context("./", false, /^\.\/payments\.tsx$/, "sync"),
|
|
66
|
+
)[0];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The store listener the payments screens ship, when they were written: the root layout mounts it for the whole
|
|
70
|
+
* run. Read synchronously, because a hook has to be there on the first render.
|
|
71
|
+
*/
|
|
72
|
+
export const storeTransactions: (() => void) | undefined = (() => {
|
|
73
|
+
const context = require.context("./", false, /^\.\/useStoreTransactions\.ts$/, "sync");
|
|
74
|
+
const key = context.keys()[0];
|
|
75
|
+
return key === undefined ? undefined : (context(key) as { useStoreTransactions: () => void }).useStoreTransactions;
|
|
76
|
+
})();
|