@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.
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 +196 -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 +673 -0
  45. package/templates/src/routes/pithy/subscription.tsx +240 -0
  46. package/templates/src/session.tsx +290 -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,253 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
3
+ import { CHECKOUT_SESSION_PARAM, getEntitlements, submitPurchase } from "@pithy-sh/payments/src/client/api";
4
+ import { useCheckout } from "@pithy-sh/payments/src/client/hooks";
5
+ import type { PaymentsClientProduct } from "@pithy-sh/payments/src/client/projection";
6
+ import { useRead } from "@pithy-sh/ui-core/src/read";
7
+ import { type ReactNode, useMemo, useRef, useState } from "react";
8
+ import { Pressable, Text, View } from "react-native";
9
+ import {
10
+ failureText,
11
+ grantsProduct,
12
+ PADDLE_IN_PAGE,
13
+ paymentsClient,
14
+ purchasePath,
15
+ returningClient,
16
+ useStoreSale,
17
+ } from "../../payments";
18
+ import { paymentsConfig } from "../../pithy-config";
19
+ import { onDevice } from "../../platform";
20
+ import { Link, useScreenPath, useSearchParam } from "../../router";
21
+ import { type Theme, useTheme } from "../../theme";
22
+ import { useAppFocus } from "../../useAppFocus";
23
+
24
+ export const path = "/paywall";
25
+
26
+ // The screen the router's entitlement guard sends people to. Rename `path` above and the guard follows, because it
27
+ // reads this claim rather than holding a copy of the string.
28
+ export const role = "paywall";
29
+
30
+ // Buying attaches a purchase to an account, so there has to be one.
31
+ export const session = "required";
32
+
33
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
34
+ //
35
+ // It renders and styles; every call it makes belongs to @pithy-sh/payments and to `../../payments`. Each product
36
+ // sells through the rail this platform is set to in `pithy-config.generated.ts`: the App Store or Google Play in
37
+ // the app, a hosted checkout in the system browser on a phone, or nothing at all under `none`.
38
+
39
+ /**
40
+ * This screen's English, baked in: the only catalog that survives being copied into your repository.
41
+ *
42
+ * `pithy.config.ts` is not in a message. A file name is not copy.
43
+ */
44
+ const EN = {
45
+ "payments/paywall.title": "Go further.",
46
+ "payments/paywall.body": "Pick what you need. You can change your mind later.",
47
+ "payments/paywall.buy": "Buy {product}",
48
+ "payments/paywall.in_app": "Available in the app.",
49
+ "payments/paywall.holdings": "What do I already have?",
50
+ "payments/paywall.restore": "Restore purchases",
51
+ "payments/paywall.nothing_to_restore": "The store has nothing to restore for this account.",
52
+ "payments/paywall.restored_elsewhere": "Your purchases are restored. None of them includes what's sold here.",
53
+ "payments/paywall.pending": "The store is still processing this purchase. It shows on your account once it clears.",
54
+ "payments/paywall.empty.title": "Nothing for sale.",
55
+ "payments/paywall.empty.body": "This project has no catalog yet. Add products to",
56
+ "payments/paywall.done.title": "You're set.",
57
+ "payments/paywall.done.body": "Thanks. Your purchase is on your account.",
58
+ "payments/paywall.done.home": "Go home",
59
+ "payments/checkout.in_page": "This checkout opens inside a web page, which the app can't show.",
60
+ "payments/store.unavailable": "The store didn't answer. Try again in a moment.",
61
+ "payments/store.no_answer":
62
+ "The store hasn't answered. If the purchase goes through, it lands on your account on its own.",
63
+ "payments/store.refused": "The store didn't finish the purchase.",
64
+ "payments/store.no_token": "This device couldn't keep what tags a purchase as yours, so nothing was bought.",
65
+ "payments/store.signed_out": "Sign in before you buy.",
66
+ } satisfies MessageCatalog;
67
+
68
+ /** One of this screen's buttons. The primary one is filled with the accent; the rest are outlined. */
69
+ function Action(props: {
70
+ theme: Theme;
71
+ label: string;
72
+ primary?: boolean;
73
+ disabled?: boolean;
74
+ onPress: () => void;
75
+ }): ReactNode {
76
+ const { theme } = props;
77
+ return (
78
+ <Pressable
79
+ role="button"
80
+ disabled={props.disabled}
81
+ aria-disabled={props.disabled}
82
+ onPress={props.onPress}
83
+ style={{
84
+ borderWidth: 1,
85
+ borderColor: props.primary ? theme.colors.accent : theme.colors.border,
86
+ backgroundColor: props.primary ? theme.colors.accent : "transparent",
87
+ borderRadius: theme.radius,
88
+ paddingVertical: theme.spacing.sm,
89
+ paddingHorizontal: theme.spacing.md,
90
+ alignSelf: "flex-start",
91
+ opacity: props.disabled ? 0.5 : 1,
92
+ }}
93
+ >
94
+ <Text style={[theme.type.button, { color: props.primary ? theme.colors.onAccent : theme.colors.text }]}>
95
+ {props.label}
96
+ </Text>
97
+ </Pressable>
98
+ );
99
+ }
100
+
101
+ export default function Paywall(): ReactNode {
102
+ const t = useTranslator(EN);
103
+ const theme = useTheme();
104
+ const muted = [theme.type.body, { color: theme.colors.muted }];
105
+ const subscriptionPath = useScreenPath("subscription");
106
+ const sale = useStoreSale();
107
+
108
+ // A hosted checkout on a phone happens in the system browser. The product the reader went to buy is held here,
109
+ // and the entitlements are read again when the browser closes and when the app returns to the foreground; once
110
+ // they grant what that product carries, the purchase has landed.
111
+ const [awaiting, setAwaiting] = useState<PaymentsClientProduct | null>(null);
112
+ const recheck = useRef<() => void>(() => undefined);
113
+ const client = useMemo(() => returningClient(() => recheck.current()), []);
114
+ const checkout = useCheckout(client);
115
+ const held = useRead(
116
+ "paywall-return",
117
+ async () => {
118
+ const result = await getEntitlements(paymentsClient);
119
+ return result.ok ? result.value : [];
120
+ },
121
+ { skip: awaiting === null },
122
+ );
123
+ recheck.current = held.refresh;
124
+ useAppFocus(() => {
125
+ if (awaiting !== null) held.refresh();
126
+ });
127
+
128
+ // Coming back from Stripe's hosted Checkout, the success link carries the session id: on the web in the page's
129
+ // query, on a phone in the universal link that opened the app. Posting it projects the purchase at once rather
130
+ // than whenever the webhook lands. Once per return. No other rail puts a session id in its return link.
131
+ const returned = useSearchParam(CHECKOUT_SESSION_PARAM) || null;
132
+ const submitted = useRead(
133
+ `checkout-return:${returned ?? ""}`,
134
+ async () => (returned === null ? null : submitPurchase({ rail: "stripe", receipt: returned }, paymentsClient)),
135
+ { skip: returned === null, once: true },
136
+ ).value;
137
+ const refused = submitted?.ok === false ? submitted.failure : null;
138
+
139
+ if (!paymentsConfig.enabled) {
140
+ return (
141
+ <View
142
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
143
+ >
144
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
145
+ {t.t("payments/paywall.empty.title")}
146
+ </Text>
147
+ <Text style={muted}>
148
+ {t.t("payments/paywall.empty.body")} <Text style={{ fontFamily: "monospace" }}>pithy.config.ts</Text>.
149
+ </Text>
150
+ </View>
151
+ );
152
+ }
153
+
154
+ // A restore can bring back purchases this paywall does not sell; the reader is set only when one of them grants
155
+ // a product on it.
156
+ const restoredHere =
157
+ sale.outcome === "restored" && paymentsConfig.products.some((product) => grantsProduct(product, sale.entitlements));
158
+ const done =
159
+ (returned !== null && submitted?.ok === true) ||
160
+ sale.outcome === "bought" ||
161
+ restoredHere ||
162
+ (awaiting !== null && grantsProduct(awaiting, held.value ?? []));
163
+
164
+ if (done) {
165
+ return (
166
+ <View
167
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
168
+ >
169
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
170
+ {t.t("payments/paywall.done.title")}
171
+ </Text>
172
+ <Text style={muted}>{t.t("payments/paywall.done.body")}</Text>
173
+ <Link to="/">{t.t("payments/paywall.done.home")}</Link>
174
+ </View>
175
+ );
176
+ }
177
+
178
+ const offered = paymentsConfig.products.map((product) => ({ product, path: purchasePath(product) }));
179
+ const sells = offered.some(({ path }) => path.kind !== "none");
180
+ const storeRail = offered.find(({ path }) => path.kind === "store")?.path;
181
+ const busy = checkout.starting || sale.busy;
182
+ // Paddle answered with a checkout for Paddle.js to draw over a page. The app has no page to draw it on.
183
+ const inPage = checkout.handoff === null ? null : PADDLE_IN_PAGE;
184
+
185
+ return (
186
+ <View
187
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
188
+ >
189
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
190
+ {t.t("payments/paywall.title")}
191
+ </Text>
192
+ {sells && <Text style={muted}>{t.t("payments/paywall.body")}</Text>}
193
+
194
+ {refused && <Text style={muted}>{failureText(t, refused)}</Text>}
195
+ {checkout.failure && <Text style={muted}>{failureText(t, checkout.failure)}</Text>}
196
+ {inPage && <Text style={muted}>{failureText(t, inPage)}</Text>}
197
+ {sale.failure && <Text style={muted}>{failureText(t, sale.failure)}</Text>}
198
+ {sale.outcome === "pending" && <Text style={muted}>{t.t("payments/paywall.pending")}</Text>}
199
+ {sale.outcome === "nothing_to_restore" && <Text style={muted}>{t.t("payments/paywall.nothing_to_restore")}</Text>}
200
+ {sale.outcome === "restored" && <Text style={muted}>{t.t("payments/paywall.restored_elsewhere")}</Text>}
201
+
202
+ <View style={{ gap: theme.spacing.lg }}>
203
+ {offered.map(({ product, path }) => (
204
+ <View key={product.id} style={{ gap: theme.spacing.xs }}>
205
+ <Text style={[theme.type.body, { color: theme.colors.text, fontWeight: "700" }]}>{product.name}</Text>
206
+ {path.kind === "store" || path.kind === "hosted" ? (
207
+ <Action
208
+ theme={theme}
209
+ primary
210
+ label={t.t("payments/paywall.buy", { product: product.name })}
211
+ disabled={busy}
212
+ onPress={() => {
213
+ if (path.kind === "store") {
214
+ void sale.buy(path.rail, { sku: path.sku, type: product.type });
215
+ return;
216
+ }
217
+ if (onDevice()) setAwaiting(product);
218
+ void checkout.start(product.id, { rail: path.rail });
219
+ }}
220
+ />
221
+ ) : path.kind === "none" ? (
222
+ // Consumption only: what the plan includes, and nothing that sells it.
223
+ product.entitlements.map((key) => (
224
+ <Text key={key} style={muted}>
225
+ {key}
226
+ </Text>
227
+ ))
228
+ ) : (
229
+ // On the web, a product sold only through the App Store or Google Play can be named and nothing more.
230
+ !onDevice() && <Text style={muted}>{t.t("payments/paywall.in_app")}</Text>
231
+ )}
232
+ </View>
233
+ ))}
234
+ </View>
235
+
236
+ {/* The store's own history, for a reader on a new device or after a reinstall. */}
237
+ {onDevice() && storeRail?.kind === "store" && (
238
+ <Action
239
+ theme={theme}
240
+ label={t.t("payments/paywall.restore")}
241
+ disabled={busy}
242
+ onPress={() => void sale.restore(storeRail.rail)}
243
+ />
244
+ )}
245
+
246
+ {sells && (
247
+ <Link to={subscriptionPath} style={{ color: theme.colors.muted }}>
248
+ {t.t("payments/paywall.holdings")}
249
+ </Link>
250
+ )}
251
+ </View>
252
+ );
253
+ }
@@ -0,0 +1,317 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
3
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
4
+ import type { PaymentsClientOptions } from "@pithy-sh/payments/src/client/api";
5
+ import { useCheckout, usePricePreview } from "@pithy-sh/payments/src/client/hooks";
6
+ import { type PaddleOptions, type PaddleSetup, priceSummary } from "@pithy-sh/payments/src/client/paddle";
7
+ import type { PaymentsClientProduct } from "@pithy-sh/payments/src/client/projection";
8
+ import type { PaymentsPurchasePath } from "@pithy-sh/payments/src/client/railChoice";
9
+ import {
10
+ type PriceVisitor,
11
+ priceQueryFor,
12
+ quoteIsEstimated,
13
+ resolvePriceLocation,
14
+ } from "@pithy-sh/payments/src/pricing/location";
15
+ import type { ReactNode } from "react";
16
+ import { Pressable, Text, View } from "react-native";
17
+ import {
18
+ failureText,
19
+ PADDLE_IN_PAGE,
20
+ paddleSetup,
21
+ paymentsClient,
22
+ purchasePath,
23
+ type StoreItem,
24
+ usePriceVisitor,
25
+ useStoreListings,
26
+ useStoreSale,
27
+ } from "../../payments";
28
+ import { paymentsConfig } from "../../pithy-config";
29
+ import { onDevice } from "../../platform";
30
+ import { Link, useOptionalScreenPath, useScreenPath, useSignedIn } from "../../router";
31
+ import { type Theme, useTheme } from "../../theme";
32
+
33
+ export const path = "/pricing";
34
+
35
+ // No session. A pricing page is the one screen a stranger has to be able to read. Buying does need an account, so
36
+ // a stranger is offered the way in, by name, before the tap.
37
+
38
+ // Pithy's screen. Yours to override: put your own file at this path under src/routes/app/ and it wins.
39
+ //
40
+ // Every figure on it comes from the store or from Paddle, for this reader. There is no price string in this file,
41
+ // and there must never be one: a number written here is wrong in every country whose tax convention differs from
42
+ // the one it was typed in, and it is wrong silently.
43
+ //
44
+ // What each product offers follows the rail this platform sells through, from `pithy-config.generated.ts`: the
45
+ // App Store or Google Play in the app, a hosted checkout (in the system browser on a phone), or, under `none`, the
46
+ // plan and what it includes with no price, no button and no link.
47
+
48
+ /**
49
+ * This screen's English, baked in: the only catalog that survives being copied into your repository. No figure is
50
+ * in here, and none ever may be.
51
+ *
52
+ * `{interval}` is Paddle's word for the billing period and arrives from their API rather than from a catalog.
53
+ */
54
+ const EN = {
55
+ "payments/pricing.title": "What it costs.",
56
+ "payments/pricing.body": "Prices are for where you are. Tax is Paddle's to calculate, not ours.",
57
+ "payments/pricing.store_body": "Prices are the store's, for where you are.",
58
+ "payments/pricing.anonymous": "Anyone can read a price. Buying needs an account.",
59
+ "payments/pricing.loading": "Getting your price.",
60
+ "payments/pricing.estimated": "Estimated.",
61
+ "payments/pricing.unavailable": "We couldn't get a price. You'll see it at checkout.",
62
+ "payments/pricing.at_checkout": "You'll see the price at checkout.",
63
+ "payments/pricing.sign_in": "Sign in to buy {product}",
64
+ "payments/pricing.buy": "Buy {product}",
65
+ "payments/pricing.done": "Thanks. Your purchase is on your account.",
66
+ "payments/pricing.pending": "The store is still processing this purchase. It shows on your account once it clears.",
67
+ "payments/pricing.holdings": "What do I already have?",
68
+ "payments/pricing.every.one": "a {interval}",
69
+ "payments/pricing.every.other": "every {count} {interval}s",
70
+ "payments/pricing.empty.title": "Nothing priced here.",
71
+ "payments/pricing.empty.rail":
72
+ "Nothing here is sold through this platform's rail. Give a product its SKU on that rail in",
73
+ "payments/checkout.in_page": "This checkout opens inside a web page, which the app can't show.",
74
+ "payments/store.unavailable": "The store didn't answer. Try again in a moment.",
75
+ "payments/store.no_answer":
76
+ "The store hasn't answered. If the purchase goes through, it lands on your account on its own.",
77
+ "payments/store.refused": "The store didn't finish the purchase.",
78
+ "payments/store.no_token": "This device couldn't keep what tags a purchase as yours, so nothing was bought.",
79
+ "payments/store.signed_out": "Sign in before you buy.",
80
+ } satisfies MessageCatalog;
81
+
82
+ /** How often a price bills, in words, through `plural`. Null for a one-off. */
83
+ function every(t: Translator, cycle: { interval: string; frequency: number } | null): string | null {
84
+ if (cycle === null) return null;
85
+ return t.plural("payments/pricing.every", cycle.frequency, { interval: cycle.interval });
86
+ }
87
+
88
+ /** One of this screen's buttons. */
89
+ function BuyButton(props: { theme: Theme; label: string; disabled: boolean; onPress: () => void }): ReactNode {
90
+ const { theme } = props;
91
+ return (
92
+ <Pressable
93
+ role="button"
94
+ disabled={props.disabled}
95
+ aria-disabled={props.disabled}
96
+ onPress={props.onPress}
97
+ style={{
98
+ backgroundColor: theme.colors.accent,
99
+ borderRadius: theme.radius,
100
+ paddingVertical: theme.spacing.sm,
101
+ paddingHorizontal: theme.spacing.md,
102
+ alignSelf: "flex-start",
103
+ opacity: props.disabled ? 0.5 : 1,
104
+ }}
105
+ >
106
+ <Text style={[theme.type.button, { color: theme.colors.onAccent }]}>{props.label}</Text>
107
+ </Pressable>
108
+ );
109
+ }
110
+
111
+ export interface PricingScreenProps {
112
+ /** The translator this screen renders through. Absent, the mounted provider's, and with none, {@link EN}. */
113
+ readonly t?: Translator;
114
+ /** The catalog. Products this platform cannot sell are left off. */
115
+ readonly products: readonly PaymentsClientProduct[];
116
+ /** How each product sells on this platform. */
117
+ readonly pathFor: (product: PaymentsClientProduct) => PaymentsPurchasePath;
118
+ /** What Paddle.js starts with, for a quote on the web; null on a phone and when the rail is off. */
119
+ readonly setup: PaddleSetup | null;
120
+ /** Whether there is a session: null while that is still being read. */
121
+ readonly signedIn: boolean | null;
122
+ /** Where "sign in to buy" points, or null in a project with no sign-in screen. */
123
+ readonly signInPath: string | null;
124
+ /** Where "what do I already have?" points: the subscription screen's declared path. */
125
+ readonly subscriptionPath: string;
126
+ /** Where this visitor is charged from, as far as is known, for a Paddle quote. */
127
+ readonly visitor: PriceVisitor | null;
128
+ /** Where the payments routes are, and the fetch to reach them with. Absent, the app's own. */
129
+ readonly client?: PaymentsClientOptions;
130
+ /** How Paddle.js is loaded. Injected so a test never reaches Paddle's CDN. */
131
+ readonly paddle?: PaddleOptions;
132
+ }
133
+
134
+ /**
135
+ * The pricing screen, taking its catalog and its visitor rather than reading them: an estimated quote has to look
136
+ * estimated, an anonymous visitor has to be offered a way in, and a consumption-only build has to show no price.
137
+ * Each is only true of what is rendered.
138
+ */
139
+ export function PricingScreen({
140
+ products,
141
+ pathFor,
142
+ setup,
143
+ signedIn,
144
+ signInPath,
145
+ subscriptionPath,
146
+ visitor,
147
+ client,
148
+ paddle,
149
+ t: given,
150
+ }: PricingScreenProps): ReactNode {
151
+ const baked = useTranslator(EN);
152
+ const t = given ?? baked;
153
+ const theme = useTheme();
154
+ const muted = [theme.type.body, { color: theme.colors.muted }];
155
+
156
+ const listed = products.flatMap((product) => {
157
+ const path = pathFor(product);
158
+ return path.kind === "unavailable" ? [] : [{ product, path }];
159
+ });
160
+ const storeItems: StoreItem[] = listed.flatMap(({ product, path }) =>
161
+ path.kind === "store" ? [{ sku: path.sku, type: product.type }] : [],
162
+ );
163
+ const store = useStoreListings(storeItems);
164
+ // A Paddle quote needs Paddle.js, which needs a browser: asked for on the web only, and only for what sells there.
165
+ const quotable = setup === null ? [] : listed.filter(({ path }) => path.kind === "hosted" && path.rail === "paddle");
166
+ const items = quotable.flatMap(({ product }) =>
167
+ product.skus.paddle ? [{ priceId: product.skus.paddle, quantity: 1 }] : [],
168
+ );
169
+ const location = resolvePriceLocation(visitor);
170
+ const quoted = usePricePreview(items.length > 0 ? setup : null, priceQueryFor(items, location), paddle);
171
+ const checkout = useCheckout(client ?? paymentsClient);
172
+ const sale = useStoreSale();
173
+
174
+ if (listed.length === 0) {
175
+ return (
176
+ <View
177
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
178
+ >
179
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
180
+ {t.t("payments/pricing.empty.title")}
181
+ </Text>
182
+ <Text style={muted}>
183
+ {t.t("payments/pricing.empty.rail")} <Text style={{ fontFamily: "monospace" }}>pithy.config.ts</Text>.
184
+ </Text>
185
+ </View>
186
+ );
187
+ }
188
+
189
+ const sells = listed.some(({ path }) => path.kind !== "none");
190
+ const busy = signedIn === null || checkout.starting || sale.busy;
191
+ // Paddle answered with a checkout for Paddle.js to draw over a page. The app has no page to draw it on.
192
+ const inPage = checkout.handoff === null ? null : PADDLE_IN_PAGE;
193
+
194
+ return (
195
+ <View
196
+ style={{ flex: 1, padding: theme.spacing.lg, gap: theme.spacing.md, backgroundColor: theme.colors.background }}
197
+ >
198
+ <Text role="heading" style={[theme.type.title, { color: theme.colors.text }]}>
199
+ {t.t("payments/pricing.title")}
200
+ </Text>
201
+ {storeItems.length > 0 && <Text style={muted}>{t.t("payments/pricing.store_body")}</Text>}
202
+ {quotable.length > 0 && <Text style={muted}>{t.t("payments/pricing.body")}</Text>}
203
+
204
+ {sells && signedIn === false && <Text style={muted}>{t.t("payments/pricing.anonymous")}</Text>}
205
+
206
+ {quoted.failure && <Text style={muted}>{failureText(t, quoted.failure)}</Text>}
207
+ {checkout.failure && <Text style={muted}>{failureText(t, checkout.failure)}</Text>}
208
+ {inPage && <Text style={muted}>{failureText(t, inPage)}</Text>}
209
+ {sale.failure && <Text style={muted}>{failureText(t, sale.failure)}</Text>}
210
+ {sale.outcome === "bought" && <Text style={muted}>{t.t("payments/pricing.done")}</Text>}
211
+ {sale.outcome === "pending" && <Text style={muted}>{t.t("payments/pricing.pending")}</Text>}
212
+
213
+ <View style={{ gap: theme.spacing.lg }}>
214
+ {listed.map(({ product, path }) => (
215
+ <View key={product.id} style={{ gap: theme.spacing.xs }}>
216
+ <Text style={[theme.type.body, { color: theme.colors.text, fontWeight: "700" }]}>{product.name}</Text>
217
+ {path.kind === "none" ? (
218
+ // Consumption only: what the plan includes, and nothing that sells it.
219
+ product.entitlements.map((key) => (
220
+ <Text key={key} style={muted}>
221
+ {key}
222
+ </Text>
223
+ ))
224
+ ) : (
225
+ <>
226
+ {path.kind === "store" ? (
227
+ // The store's own figure, in the reader's storefront currency with its tax convention.
228
+ store.loading ? (
229
+ <Text style={muted}>{t.t("payments/pricing.loading")}</Text>
230
+ ) : store.listings?.get(path.sku) ? (
231
+ <Text style={[theme.type.body, { color: theme.colors.text, fontWeight: "700" }]}>
232
+ {store.listings.get(path.sku)?.displayPrice}
233
+ </Text>
234
+ ) : (
235
+ <Text style={muted}>{t.t("payments/pricing.unavailable")}</Text>
236
+ )
237
+ ) : path.rail === "paddle" && setup !== null ? (
238
+ <PaddleQuote t={t} theme={theme} product={product} quoted={quoted} location={location} />
239
+ ) : (
240
+ <Text style={muted}>{t.t("payments/pricing.at_checkout")}</Text>
241
+ )}
242
+ {signedIn === false ? (
243
+ signInPath && (
244
+ <Link to={signInPath}>{t.t("payments/pricing.sign_in", { product: product.name })}</Link>
245
+ )
246
+ ) : (
247
+ <BuyButton
248
+ theme={theme}
249
+ label={t.t("payments/pricing.buy", { product: product.name })}
250
+ disabled={busy}
251
+ onPress={() =>
252
+ void (path.kind === "store"
253
+ ? sale.buy(path.rail, { sku: path.sku, type: product.type })
254
+ : checkout.start(product.id, { rail: path.rail }))
255
+ }
256
+ />
257
+ )}
258
+ </>
259
+ )}
260
+ </View>
261
+ ))}
262
+ </View>
263
+
264
+ {sells && (
265
+ <Link to={subscriptionPath} style={{ color: theme.colors.muted }}>
266
+ {t.t("payments/pricing.holdings")}
267
+ </Link>
268
+ )}
269
+ </View>
270
+ );
271
+ }
272
+
273
+ /** One product's Paddle quote: loading, the figure with its honesty labels, or no figure at all. */
274
+ function PaddleQuote(props: {
275
+ t: Translator;
276
+ theme: Theme;
277
+ product: PaymentsClientProduct;
278
+ quoted: ReturnType<typeof usePricePreview>;
279
+ location: ReturnType<typeof resolvePriceLocation>;
280
+ }): ReactNode {
281
+ const { t, theme, product, quoted, location } = props;
282
+ const muted = [theme.type.body, { color: theme.colors.muted }];
283
+ if (quoted.loading) return <Text style={muted}>{t.t("payments/pricing.loading")}</Text>;
284
+ const line = quoted.preview?.lines.find((quote) => quote.priceId === product.skus.paddle) ?? null;
285
+ const summary = line && quoted.preview ? priceSummary(quoted.preview, line) : null;
286
+ if (!summary || !line) return <Text style={muted}>{t.t("payments/pricing.unavailable")}</Text>;
287
+ const cycle = every(t, line.billingCycle);
288
+ return (
289
+ <Text style={[theme.type.body, { color: theme.colors.text }]}>
290
+ <Text style={{ fontWeight: "700" }}>{summary.headline}</Text>
291
+ {cycle && <Text style={{ color: theme.colors.muted }}> {cycle}</Text>}
292
+ {/* An estimate is labelled before the tap: the tax is not fully resolved, or the location is a guess. */}
293
+ {quoteIsEstimated(location, summary.estimated) && (
294
+ <Text style={{ color: theme.colors.muted }}> {t.t("payments/pricing.estimated")}</Text>
295
+ )}
296
+ {summary.note && <Text style={{ color: theme.colors.muted }}> {summary.note}</Text>}
297
+ </Text>
298
+ );
299
+ }
300
+
301
+ export default function Pricing(): ReactNode {
302
+ const signedIn = useSignedIn();
303
+ const signInPath = useOptionalScreenPath("sign-in");
304
+ const subscriptionPath = useScreenPath("subscription");
305
+ const visitor = usePriceVisitor(signedIn === true && !onDevice(), paymentsClient);
306
+ return (
307
+ <PricingScreen
308
+ products={paymentsConfig.enabled ? paymentsConfig.products : []}
309
+ pathFor={purchasePath}
310
+ setup={onDevice() ? null : paddleSetup}
311
+ signedIn={signedIn}
312
+ signInPath={signInPath}
313
+ subscriptionPath={subscriptionPath}
314
+ visitor={visitor}
315
+ />
316
+ );
317
+ }