@vvedantb/native-ui 0.2.1 → 0.3.13
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/README.md +171 -9
- package/package.json +84 -2
- package/src/AlertDialog.tsx +73 -0
- package/src/AmbientBackground.tsx +26 -11
- package/src/Avatar.tsx +141 -0
- package/src/Badge.tsx +108 -0
- package/src/BottomSheetContainer.tsx +7 -1
- package/src/Button.tsx +153 -52
- package/src/Card.tsx +111 -36
- package/src/Checkbox.tsx +78 -0
- package/src/Chip.tsx +5 -3
- package/src/ConfirmDialog.tsx +105 -0
- package/src/DateTimeField.tsx +6 -2
- package/src/Dialog.tsx +253 -0
- package/src/EmptyState.tsx +122 -7
- package/src/FloatingFAB.tsx +2 -4
- package/src/FloatingSurface.tsx +31 -9
- package/src/FloatingTabBar.tsx +39 -11
- package/src/Input.tsx +15 -4
- package/src/Label.tsx +31 -0
- package/src/LabeledSwitchRow.tsx +96 -0
- package/src/LiquidGlass.tsx +2 -10
- package/src/ListRow.tsx +7 -2
- package/src/PageTitle.tsx +81 -0
- package/src/PillTabs.tsx +28 -14
- package/src/Progress.tsx +67 -0
- package/src/SearchInput.tsx +163 -0
- package/src/Select.tsx +32 -6
- package/src/Separator.tsx +38 -0
- package/src/Sheet.tsx +107 -0
- package/src/Skeleton.tsx +25 -5
- package/src/Spinner.tsx +92 -0
- package/src/StatusDot.tsx +66 -0
- package/src/Switch.tsx +24 -7
- package/src/Tabs.tsx +190 -0
- package/src/Textarea.tsx +47 -0
- package/src/Toast.tsx +170 -0
- package/src/blur.ts +48 -0
- package/src/brand/AppIcons.tsx +106 -0
- package/src/brand/AppMark.tsx +83 -0
- package/src/brand/AppWordmark.tsx +97 -0
- package/src/brand/familyApps.ts +24 -0
- package/src/brand/index.ts +34 -0
- package/src/brand/svg.ts +263 -0
- package/src/brand/vProAppBenefits.ts +22 -0
- package/src/controlStyles.ts +395 -0
- package/src/dialogLayout.ts +20 -0
- package/src/entries/ambient.ts +3 -0
- package/src/entries/bottom-sheet.ts +2 -0
- package/src/entries/brand.ts +2 -0
- package/src/entries/button.ts +3 -0
- package/src/entries/date-time.ts +3 -0
- package/src/entries/floating.ts +24 -0
- package/src/entries/layout.ts +10 -0
- package/src/entries/marketing.ts +10 -0
- package/src/entries/page-title.ts +4 -0
- package/src/entries/primitives.ts +99 -0
- package/src/entries/search-input.ts +4 -0
- package/src/entries/tab-bar.ts +10 -0
- package/src/entries/theme.ts +15 -0
- package/src/entries/toast.ts +6 -0
- package/src/examples/ParityGallery.tsx +513 -0
- package/src/floating.ts +5 -1
- package/src/glassBlur.tsx +95 -0
- package/src/gradient.ts +21 -0
- package/src/haptics.ts +22 -2
- package/src/index.ts +101 -2
- package/src/initials.ts +10 -0
- package/src/marketing/PricingModal.tsx +410 -0
- package/src/marketing/UpgradeToProCard.tsx +103 -0
- package/src/marketing/pricing.ts +76 -0
- package/src/palette.ts +43 -0
- package/src/primitives/button.tsx +3 -1
- package/src/primitives/card.tsx +5 -2
- package/src/primitives/input.tsx +9 -2
- package/src/primitives/native-only-animated-view.tsx +10 -3
- package/src/primitives/overlay.tsx +179 -0
- package/src/primitives/select.tsx +25 -12
- package/src/primitives/switch.tsx +12 -13
- package/src/primitives/text.tsx +25 -8
- package/src/primitives/use-controllable-state.ts +24 -0
- package/src/settings/SettingsField.tsx +11 -2
- package/src/settings/SettingsSection.tsx +20 -6
- package/src/settings/SettingsToggleRow.tsx +7 -2
- package/src/tabBarRoutes.ts +52 -0
- package/src/toastChrome.ts +103 -0
- package/src/toastStore.ts +86 -0
- package/src/tokens.ts +19 -14
- package/src/typography.ts +24 -0
- package/src/useReduceMotion.ts +24 -0
- package/src/useScheme.ts +6 -0
package/src/index.ts
CHANGED
|
@@ -8,6 +8,9 @@ export {
|
|
|
8
8
|
DEFAULT_ACCENT,
|
|
9
9
|
} from "./theme";
|
|
10
10
|
export type { NativeUiTheme } from "./theme";
|
|
11
|
+
export { palette, withAlpha } from "./palette";
|
|
12
|
+
export type { Palette } from "./palette";
|
|
13
|
+
export { fontClass } from "./typography";
|
|
11
14
|
export {
|
|
12
15
|
FLOATING_INSET,
|
|
13
16
|
FLOATING_TAB_BAR_HEIGHT,
|
|
@@ -29,30 +32,126 @@ export {
|
|
|
29
32
|
GlassBlurProvider,
|
|
30
33
|
GlassBlurTarget,
|
|
31
34
|
} from "./LiquidGlass";
|
|
35
|
+
export type { GlassBlurTargetProps } from "./LiquidGlass";
|
|
32
36
|
export { AmbientBackground } from "./AmbientBackground";
|
|
33
37
|
export { FloatingTabBar } from "./FloatingTabBar";
|
|
38
|
+
export type { FloatingTabBarProps } from "./FloatingTabBar";
|
|
34
39
|
export { FloatingFAB } from "./FloatingFAB";
|
|
35
40
|
export { Button } from "./Button";
|
|
36
|
-
export type { ButtonProps } from "./Button";
|
|
41
|
+
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
|
|
37
42
|
export { Input } from "./Input";
|
|
38
43
|
export type { InputProps } from "./Input";
|
|
44
|
+
export { SearchInput } from "./SearchInput";
|
|
45
|
+
export type { SearchInputProps } from "./SearchInput";
|
|
46
|
+
export { Textarea } from "./Textarea";
|
|
47
|
+
export type { TextareaProps } from "./Textarea";
|
|
48
|
+
export { Label } from "./Label";
|
|
49
|
+
export type { LabelProps } from "./Label";
|
|
39
50
|
export { Select } from "./Select";
|
|
40
51
|
export type { SelectOption, SelectProps } from "./Select";
|
|
41
52
|
export { Switch } from "./Switch";
|
|
42
53
|
export type { SwitchProps } from "./Switch";
|
|
54
|
+
export { LabeledSwitchRow } from "./LabeledSwitchRow";
|
|
55
|
+
export type { LabeledSwitchRowProps } from "./LabeledSwitchRow";
|
|
56
|
+
export { Checkbox } from "./Checkbox";
|
|
57
|
+
export type { CheckboxProps } from "./Checkbox";
|
|
58
|
+
export { Badge } from "./Badge";
|
|
59
|
+
export type { BadgeProps, BadgeVariant } from "./Badge";
|
|
60
|
+
export { StatusDot } from "./StatusDot";
|
|
61
|
+
export type { StatusDotProps, StatusTone } from "./StatusDot";
|
|
62
|
+
export { Separator } from "./Separator";
|
|
63
|
+
export type { SeparatorProps } from "./Separator";
|
|
64
|
+
export { Progress } from "./Progress";
|
|
65
|
+
export type { ProgressProps } from "./Progress";
|
|
66
|
+
export { Spinner, CenteredSpinner } from "./Spinner";
|
|
67
|
+
export type { SpinnerProps } from "./Spinner";
|
|
68
|
+
export { Avatar, AvatarImage, AvatarFallback } from "./Avatar";
|
|
69
|
+
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from "./Avatar";
|
|
70
|
+
export { initials } from "./initials";
|
|
43
71
|
export { Chip } from "./Chip";
|
|
44
72
|
export { PillTabs } from "./PillTabs";
|
|
73
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs";
|
|
74
|
+
export type {
|
|
75
|
+
TabsProps,
|
|
76
|
+
TabsListProps,
|
|
77
|
+
TabsListVariant,
|
|
78
|
+
TabsTriggerProps,
|
|
79
|
+
TabsContentProps,
|
|
80
|
+
} from "./Tabs";
|
|
45
81
|
export { ListRow } from "./ListRow";
|
|
46
82
|
export { HeaderIconButton } from "./HeaderIconButton";
|
|
47
|
-
export {
|
|
83
|
+
export {
|
|
84
|
+
Card,
|
|
85
|
+
CardHeader,
|
|
86
|
+
CardTitle,
|
|
87
|
+
CardDescription,
|
|
88
|
+
CardContent,
|
|
89
|
+
CardFooter,
|
|
90
|
+
} from "./Card";
|
|
48
91
|
export { Skeleton, CardSkeleton } from "./Skeleton";
|
|
49
92
|
export { EmptyState } from "./EmptyState";
|
|
93
|
+
export type { EmptyStateVariant } from "./EmptyState";
|
|
94
|
+
export {
|
|
95
|
+
Dialog,
|
|
96
|
+
DialogTrigger,
|
|
97
|
+
DialogClose,
|
|
98
|
+
DialogContent,
|
|
99
|
+
DialogHeader,
|
|
100
|
+
DialogTitle,
|
|
101
|
+
DialogDescription,
|
|
102
|
+
DialogBody,
|
|
103
|
+
DialogFooter,
|
|
104
|
+
} from "./Dialog";
|
|
105
|
+
export type { DialogProps, DialogContentProps } from "./Dialog";
|
|
106
|
+
export {
|
|
107
|
+
AlertDialog,
|
|
108
|
+
AlertDialogTrigger,
|
|
109
|
+
AlertDialogContent,
|
|
110
|
+
AlertDialogHeader,
|
|
111
|
+
AlertDialogTitle,
|
|
112
|
+
AlertDialogDescription,
|
|
113
|
+
AlertDialogFooter,
|
|
114
|
+
AlertDialogAction,
|
|
115
|
+
AlertDialogCancel,
|
|
116
|
+
} from "./AlertDialog";
|
|
117
|
+
export type { AlertDialogProps, AlertDialogContentProps } from "./AlertDialog";
|
|
118
|
+
export { ConfirmDialog } from "./ConfirmDialog";
|
|
119
|
+
export type { ConfirmDialogProps } from "./ConfirmDialog";
|
|
120
|
+
export {
|
|
121
|
+
Sheet,
|
|
122
|
+
SheetTrigger,
|
|
123
|
+
SheetClose,
|
|
124
|
+
SheetContent,
|
|
125
|
+
SheetHandle,
|
|
126
|
+
SheetHeader,
|
|
127
|
+
SheetTitle,
|
|
128
|
+
SheetDescription,
|
|
129
|
+
SheetBody,
|
|
130
|
+
SheetFooter,
|
|
131
|
+
} from "./Sheet";
|
|
132
|
+
export type { SheetProps, SheetContentProps } from "./Sheet";
|
|
133
|
+
export { Toaster } from "./Toast";
|
|
134
|
+
export { toast } from "./toastStore";
|
|
135
|
+
export type { ToastOptions, ToastVariant } from "./toastStore";
|
|
50
136
|
export { DateTimeField } from "./DateTimeField";
|
|
51
137
|
export { BottomSheetContainer } from "./BottomSheetContainer";
|
|
52
138
|
export { SafeAreaContainer } from "./SafeAreaContainer";
|
|
53
139
|
export { ScreenContainer } from "./ScreenContainer";
|
|
140
|
+
export { PageTitle, PageHeader } from "./PageTitle";
|
|
141
|
+
export type { PageTitleProps, PageHeaderProps } from "./PageTitle";
|
|
54
142
|
export { SettingsSection } from "./settings/SettingsSection";
|
|
55
143
|
export { SettingsField } from "./settings/SettingsField";
|
|
56
144
|
export { SettingsToggleRow } from "./settings/SettingsToggleRow";
|
|
57
145
|
export { SettingsStack } from "./settings/SettingsStack";
|
|
58
146
|
export { PINK_50, PINK_100, PINK_600, PINK_700, NEUTRAL_100, NEUTRAL_500, NEUTRAL_900, Colors } from "./colors";
|
|
147
|
+
// Family app marks and their pure data (copied from `@vvedantb/ui/brand`).
|
|
148
|
+
export * from "./brand";
|
|
149
|
+
export { UpgradeToProCard } from "./marketing/UpgradeToProCard";
|
|
150
|
+
export type { UpgradeToProCardProps } from "./marketing/UpgradeToProCard";
|
|
151
|
+
export { PricingModal } from "./marketing/PricingModal";
|
|
152
|
+
export type { PricingModalProps } from "./marketing/PricingModal";
|
|
153
|
+
export type {
|
|
154
|
+
PricingModalPeriod,
|
|
155
|
+
PricingPlan,
|
|
156
|
+
PricingPlanBenefit,
|
|
157
|
+
} from "./marketing/pricing";
|
package/src/initials.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Up to `max` uppercase initials from a name: "Ada Lovelace" → "AL", " cher " → "C". Empty for a blank name. */
|
|
2
|
+
export function initials(name: string, max = 2): string {
|
|
3
|
+
return name
|
|
4
|
+
.trim()
|
|
5
|
+
.split(/\s+/)
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
.slice(0, max)
|
|
8
|
+
.map((word) => Array.from(word)[0]!.toUpperCase())
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Modal,
|
|
4
|
+
Pressable,
|
|
5
|
+
ScrollView,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
11
|
+
import { IconCheck, IconX } from "@tabler/icons-react-native";
|
|
12
|
+
import { APP_ICONS, IconProjectV } from "../brand/AppIcons";
|
|
13
|
+
import type { ProductAppName } from "../brand/AppWordmark";
|
|
14
|
+
import { familyApp } from "../brand/familyApps";
|
|
15
|
+
import type { AppName } from "../brand/svg";
|
|
16
|
+
import { Button } from "../Button";
|
|
17
|
+
import { cn } from "../cn";
|
|
18
|
+
import { typeStyle } from "../controlStyles";
|
|
19
|
+
import { palette, withAlpha } from "../palette";
|
|
20
|
+
import { tokens } from "../tokens";
|
|
21
|
+
import { useLargerText } from "../theme";
|
|
22
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
23
|
+
import { useScheme } from "../useScheme";
|
|
24
|
+
import { useControllableState } from "../primitives/use-controllable-state";
|
|
25
|
+
import {
|
|
26
|
+
isEmphasised,
|
|
27
|
+
planAppBenefits,
|
|
28
|
+
planButton,
|
|
29
|
+
sortAppBenefits,
|
|
30
|
+
type PricingModalPeriod,
|
|
31
|
+
type PricingPlan,
|
|
32
|
+
} from "./pricing";
|
|
33
|
+
|
|
34
|
+
export type PricingModalProps = {
|
|
35
|
+
open: boolean;
|
|
36
|
+
/** Called by the close button and Android back (and an iOS swipe-down). */
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
/** Accessible title, read first. Not visible. */
|
|
39
|
+
title: string;
|
|
40
|
+
/** Accessible description. Not visible. */
|
|
41
|
+
description?: string;
|
|
42
|
+
/** Cards, top to bottom. */
|
|
43
|
+
plans: readonly PricingPlan[];
|
|
44
|
+
/** App marks at the top left. */
|
|
45
|
+
apps?: readonly AppName[];
|
|
46
|
+
/** App the modal is shown in. Its `appBenefits` group comes first. */
|
|
47
|
+
currentApp?: ProductAppName;
|
|
48
|
+
/** Centred headline, for example "One plan. Every V app." */
|
|
49
|
+
headline?: ReactNode;
|
|
50
|
+
/** Small print under the cards. */
|
|
51
|
+
finePrint?: ReactNode;
|
|
52
|
+
billingPeriod?: PricingModalPeriod;
|
|
53
|
+
onBillingPeriodChange?: (period: PricingModalPeriod) => void;
|
|
54
|
+
/** Default `"month"`. */
|
|
55
|
+
defaultBillingPeriod?: PricingModalPeriod;
|
|
56
|
+
/** Called by a plan's button. Not called for the `current` plan. Route it to the store's purchase flow. */
|
|
57
|
+
onCheckout?: (plan: PricingPlan, period: PricingModalPeriod) => void;
|
|
58
|
+
/** Wraps each plan's button (the web `renderCheckout`). The button already calls `onCheckout`. Not called for the `current` plan. */
|
|
59
|
+
renderCheckout?: (
|
|
60
|
+
button: ReactElement,
|
|
61
|
+
plan: PricingPlan,
|
|
62
|
+
period: PricingModalPeriod,
|
|
63
|
+
) => ReactNode;
|
|
64
|
+
/** Called when an app mark is pressed. Without it the marks are not pressable. */
|
|
65
|
+
onAppPress?: (app: AppName, url: string) => void;
|
|
66
|
+
testID?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Upgrade sheet, the native counterpart of the web `PricingModal` multi-plan
|
|
71
|
+
* layout: a page sheet on iOS (full screen on Android) with a Monthly / Annual
|
|
72
|
+
* switch and stacked plan cards. The web single-plan layout (logo URLs and a
|
|
73
|
+
* Michroma wordmark) is not ported: pass one plan instead.
|
|
74
|
+
*/
|
|
75
|
+
export function PricingModal({
|
|
76
|
+
open,
|
|
77
|
+
onClose,
|
|
78
|
+
title,
|
|
79
|
+
description,
|
|
80
|
+
plans,
|
|
81
|
+
apps,
|
|
82
|
+
currentApp,
|
|
83
|
+
headline,
|
|
84
|
+
finePrint,
|
|
85
|
+
billingPeriod,
|
|
86
|
+
onBillingPeriodChange,
|
|
87
|
+
defaultBillingPeriod = "month",
|
|
88
|
+
onCheckout,
|
|
89
|
+
renderCheckout,
|
|
90
|
+
onAppPress,
|
|
91
|
+
testID,
|
|
92
|
+
}: PricingModalProps) {
|
|
93
|
+
const scheme = useScheme();
|
|
94
|
+
const largerText = useLargerText();
|
|
95
|
+
const insets = useSafeAreaInsets();
|
|
96
|
+
const reduceMotion = useReduceMotion();
|
|
97
|
+
const [period, setPeriod] = useControllableState({
|
|
98
|
+
value: billingPeriod,
|
|
99
|
+
defaultValue: defaultBillingPeriod,
|
|
100
|
+
onChange: onBillingPeriodChange,
|
|
101
|
+
});
|
|
102
|
+
const fg = tokens.color.foreground[scheme];
|
|
103
|
+
const muted = tokens.color.muted[scheme];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Modal
|
|
107
|
+
visible={open}
|
|
108
|
+
animationType={reduceMotion ? "none" : "slide"}
|
|
109
|
+
presentationStyle="pageSheet"
|
|
110
|
+
onRequestClose={onClose}
|
|
111
|
+
testID={testID}
|
|
112
|
+
>
|
|
113
|
+
<View
|
|
114
|
+
className="flex-1"
|
|
115
|
+
style={{
|
|
116
|
+
backgroundColor: tokens.color.background[scheme],
|
|
117
|
+
paddingBottom: insets.bottom,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{/* The web `sr-only` DialogTitle: a visually hidden header. */}
|
|
121
|
+
<View
|
|
122
|
+
accessible
|
|
123
|
+
accessibilityRole="header"
|
|
124
|
+
accessibilityLabel={title}
|
|
125
|
+
accessibilityHint={description}
|
|
126
|
+
style={styles.srOnly}
|
|
127
|
+
/>
|
|
128
|
+
<View className="flex-row items-center justify-between gap-3 px-4 pb-3 pt-4">
|
|
129
|
+
<View className="flex-row items-center gap-3">
|
|
130
|
+
{apps?.map((app) => {
|
|
131
|
+
const Icon = APP_ICONS[app];
|
|
132
|
+
const { name, url } = familyApp(app);
|
|
133
|
+
return onAppPress ? (
|
|
134
|
+
<Pressable
|
|
135
|
+
key={app}
|
|
136
|
+
accessibilityRole="link"
|
|
137
|
+
accessibilityLabel={name}
|
|
138
|
+
hitSlop={8}
|
|
139
|
+
onPress={() => onAppPress(app, url)}
|
|
140
|
+
className="active:opacity-70"
|
|
141
|
+
>
|
|
142
|
+
<Icon size={22} color={fg} />
|
|
143
|
+
</Pressable>
|
|
144
|
+
) : (
|
|
145
|
+
<Icon key={app} size={22} color={fg} title={name} />
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</View>
|
|
149
|
+
<View className="flex-row items-center gap-2">
|
|
150
|
+
<View
|
|
151
|
+
accessibilityLabel="Billing period"
|
|
152
|
+
className="flex-row rounded-full p-0.5"
|
|
153
|
+
style={{ backgroundColor: palette.surfaceTertiary[scheme] }}
|
|
154
|
+
>
|
|
155
|
+
{(["month", "year"] as const).map((value) => {
|
|
156
|
+
const selected = period === value;
|
|
157
|
+
return (
|
|
158
|
+
<Pressable
|
|
159
|
+
key={value}
|
|
160
|
+
testID={testID ? `${testID}-period-${value}` : undefined}
|
|
161
|
+
accessibilityRole="button"
|
|
162
|
+
accessibilityState={{ selected }}
|
|
163
|
+
hitSlop={6}
|
|
164
|
+
onPress={() => setPeriod(value)}
|
|
165
|
+
className="rounded-full px-3 py-1.5"
|
|
166
|
+
style={selected ? { backgroundColor: palette.segment[scheme] } : null}
|
|
167
|
+
>
|
|
168
|
+
<Text
|
|
169
|
+
className={cn(
|
|
170
|
+
"font-instrument-sans-medium",
|
|
171
|
+
largerText ? "text-base" : "text-sm",
|
|
172
|
+
)}
|
|
173
|
+
style={[
|
|
174
|
+
{ color: selected ? palette.segmentForeground[scheme] : muted },
|
|
175
|
+
typeStyle("medium"),
|
|
176
|
+
]}
|
|
177
|
+
>
|
|
178
|
+
{value === "month" ? "Monthly" : "Annual"}
|
|
179
|
+
</Text>
|
|
180
|
+
</Pressable>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</View>
|
|
184
|
+
<Pressable
|
|
185
|
+
testID={testID ? `${testID}-close` : undefined}
|
|
186
|
+
accessibilityRole="button"
|
|
187
|
+
accessibilityLabel="Close"
|
|
188
|
+
hitSlop={8}
|
|
189
|
+
onPress={onClose}
|
|
190
|
+
className="h-10 w-10 items-center justify-center rounded-full active:opacity-70"
|
|
191
|
+
>
|
|
192
|
+
<IconX size={22} color={muted} />
|
|
193
|
+
</Pressable>
|
|
194
|
+
</View>
|
|
195
|
+
</View>
|
|
196
|
+
|
|
197
|
+
<ScrollView
|
|
198
|
+
className="flex-1"
|
|
199
|
+
contentContainerClassName="gap-3 px-4 pb-6"
|
|
200
|
+
showsVerticalScrollIndicator={false}
|
|
201
|
+
>
|
|
202
|
+
{headline ? (
|
|
203
|
+
<Text
|
|
204
|
+
className={cn(
|
|
205
|
+
"pb-2 text-center font-instrument-sans-medium",
|
|
206
|
+
largerText ? "text-3xl" : "text-2xl",
|
|
207
|
+
)}
|
|
208
|
+
style={[{ color: fg }, typeStyle("medium")]}
|
|
209
|
+
>
|
|
210
|
+
{headline}
|
|
211
|
+
</Text>
|
|
212
|
+
) : null}
|
|
213
|
+
|
|
214
|
+
{plans.map((plan) => (
|
|
215
|
+
<PlanCard
|
|
216
|
+
key={plan.id}
|
|
217
|
+
plan={plan}
|
|
218
|
+
period={period}
|
|
219
|
+
currentApp={currentApp}
|
|
220
|
+
onCheckout={onCheckout}
|
|
221
|
+
renderCheckout={renderCheckout}
|
|
222
|
+
/>
|
|
223
|
+
))}
|
|
224
|
+
|
|
225
|
+
{finePrint ? (
|
|
226
|
+
<Text className="px-1 pt-1 font-instrument-sans text-sm" style={{ color: muted }}>
|
|
227
|
+
{finePrint}
|
|
228
|
+
</Text>
|
|
229
|
+
) : null}
|
|
230
|
+
</ScrollView>
|
|
231
|
+
</View>
|
|
232
|
+
</Modal>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function PlanCard({
|
|
237
|
+
plan,
|
|
238
|
+
period,
|
|
239
|
+
currentApp,
|
|
240
|
+
onCheckout,
|
|
241
|
+
renderCheckout,
|
|
242
|
+
}: {
|
|
243
|
+
plan: PricingPlan;
|
|
244
|
+
period: PricingModalPeriod;
|
|
245
|
+
currentApp?: ProductAppName;
|
|
246
|
+
onCheckout: PricingModalProps["onCheckout"];
|
|
247
|
+
renderCheckout: PricingModalProps["renderCheckout"];
|
|
248
|
+
}) {
|
|
249
|
+
const scheme = useScheme();
|
|
250
|
+
const largerText = useLargerText();
|
|
251
|
+
const fg = tokens.color.foreground[scheme];
|
|
252
|
+
const muted = tokens.color.muted[scheme];
|
|
253
|
+
const separator = tokens.color.separator[scheme];
|
|
254
|
+
const emphasised = isEmphasised(plan);
|
|
255
|
+
const appBenefits = planAppBenefits(plan);
|
|
256
|
+
const cta = planButton(plan);
|
|
257
|
+
const bodyText = largerText ? "text-lg" : "text-base";
|
|
258
|
+
|
|
259
|
+
const button = (
|
|
260
|
+
<Button
|
|
261
|
+
variant={cta.variant}
|
|
262
|
+
disabled={cta.disabled}
|
|
263
|
+
shadowType="flat"
|
|
264
|
+
onPress={() => onCheckout?.(plan, period)}
|
|
265
|
+
>
|
|
266
|
+
{cta.label}
|
|
267
|
+
</Button>
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<View
|
|
272
|
+
className="p-5"
|
|
273
|
+
style={{
|
|
274
|
+
borderRadius: tokens.radius.card,
|
|
275
|
+
borderWidth: 1,
|
|
276
|
+
borderColor: emphasised ? withAlpha(fg, 0.25) : separator,
|
|
277
|
+
backgroundColor: tokens.color.surfaceSecondary[scheme],
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<View className="flex-row items-center gap-2">
|
|
281
|
+
{emphasised ? <IconProjectV size={16} color={fg} /> : null}
|
|
282
|
+
<Text
|
|
283
|
+
accessibilityRole="header"
|
|
284
|
+
className={cn("font-instrument-sans-medium", bodyText)}
|
|
285
|
+
style={[{ color: fg }, typeStyle("medium")]}
|
|
286
|
+
>
|
|
287
|
+
{plan.name}
|
|
288
|
+
</Text>
|
|
289
|
+
</View>
|
|
290
|
+
|
|
291
|
+
<View className="mt-4 flex-row items-baseline gap-1">
|
|
292
|
+
<Text
|
|
293
|
+
className="font-instrument-sans-medium"
|
|
294
|
+
style={[
|
|
295
|
+
{
|
|
296
|
+
color: fg,
|
|
297
|
+
fontSize: 48,
|
|
298
|
+
lineHeight: 52,
|
|
299
|
+
letterSpacing: -0.5,
|
|
300
|
+
fontVariant: ["tabular-nums"],
|
|
301
|
+
},
|
|
302
|
+
typeStyle("medium"),
|
|
303
|
+
]}
|
|
304
|
+
>
|
|
305
|
+
{plan.price[period]}
|
|
306
|
+
</Text>
|
|
307
|
+
<Text className={cn("font-instrument-sans", bodyText)} style={{ color: muted }}>
|
|
308
|
+
{plan.periodSuffix ?? "/month"}
|
|
309
|
+
</Text>
|
|
310
|
+
</View>
|
|
311
|
+
|
|
312
|
+
<View className="mt-5">
|
|
313
|
+
{plan.current || !renderCheckout ? button : renderCheckout(button, plan, period)}
|
|
314
|
+
</View>
|
|
315
|
+
|
|
316
|
+
{plan.intro ? (
|
|
317
|
+
<Text className={cn("mt-6 font-instrument-sans", bodyText)} style={{ color: muted }}>
|
|
318
|
+
{plan.intro}
|
|
319
|
+
</Text>
|
|
320
|
+
) : null}
|
|
321
|
+
|
|
322
|
+
{plan.highlight ? (
|
|
323
|
+
<View className="mt-3">
|
|
324
|
+
<Text
|
|
325
|
+
className="font-instrument-sans-medium text-2xl"
|
|
326
|
+
style={[{ color: fg }, typeStyle("medium")]}
|
|
327
|
+
>
|
|
328
|
+
{plan.highlight.title}
|
|
329
|
+
</Text>
|
|
330
|
+
<Text className={cn("font-instrument-sans", bodyText)} style={{ color: fg }}>
|
|
331
|
+
{plan.highlight.description}
|
|
332
|
+
</Text>
|
|
333
|
+
</View>
|
|
334
|
+
) : null}
|
|
335
|
+
|
|
336
|
+
{plan.benefits?.length ? (
|
|
337
|
+
<View
|
|
338
|
+
className="mt-5 gap-2.5 pt-4"
|
|
339
|
+
style={{ borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: separator }}
|
|
340
|
+
>
|
|
341
|
+
{plan.benefits.map((benefit, index) => {
|
|
342
|
+
const BenefitIcon =
|
|
343
|
+
benefit.icon && benefit.icon !== "check" ? APP_ICONS[benefit.icon] : null;
|
|
344
|
+
return (
|
|
345
|
+
<View key={index} className="flex-row items-start gap-2.5">
|
|
346
|
+
<View className="mt-1 h-4 w-5 items-center justify-center">
|
|
347
|
+
{BenefitIcon ? (
|
|
348
|
+
<BenefitIcon size={14} color={benefit.muted ? muted : fg} />
|
|
349
|
+
) : (
|
|
350
|
+
<IconCheck size={16} strokeWidth={2} color={benefit.muted ? muted : fg} />
|
|
351
|
+
)}
|
|
352
|
+
</View>
|
|
353
|
+
<Text
|
|
354
|
+
className={cn("min-w-0 flex-1 font-instrument-sans", bodyText)}
|
|
355
|
+
style={{ color: benefit.muted ? muted : fg }}
|
|
356
|
+
>
|
|
357
|
+
{benefit.label}
|
|
358
|
+
</Text>
|
|
359
|
+
</View>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
</View>
|
|
363
|
+
) : null}
|
|
364
|
+
|
|
365
|
+
{appBenefits?.length ? (
|
|
366
|
+
<View
|
|
367
|
+
className="mt-4 gap-3 pt-3"
|
|
368
|
+
style={{ borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: separator }}
|
|
369
|
+
>
|
|
370
|
+
{sortAppBenefits(appBenefits, currentApp).map((group) => {
|
|
371
|
+
const GroupIcon = APP_ICONS[group.app];
|
|
372
|
+
return (
|
|
373
|
+
<View key={group.app}>
|
|
374
|
+
<View className="flex-row items-center gap-2">
|
|
375
|
+
<GroupIcon size={14} color={fg} />
|
|
376
|
+
<Text
|
|
377
|
+
className="font-instrument-sans-medium text-sm"
|
|
378
|
+
style={[{ color: fg }, typeStyle("medium")]}
|
|
379
|
+
>
|
|
380
|
+
{familyApp(group.app).name}
|
|
381
|
+
</Text>
|
|
382
|
+
</View>
|
|
383
|
+
<View className="mt-1 gap-0.5 pl-[22px]">
|
|
384
|
+
{group.items.map((item, index) => (
|
|
385
|
+
<Text
|
|
386
|
+
key={index}
|
|
387
|
+
className={cn("font-instrument-sans", bodyText)}
|
|
388
|
+
style={{ color: fg }}
|
|
389
|
+
>
|
|
390
|
+
{item}
|
|
391
|
+
</Text>
|
|
392
|
+
))}
|
|
393
|
+
</View>
|
|
394
|
+
</View>
|
|
395
|
+
);
|
|
396
|
+
})}
|
|
397
|
+
</View>
|
|
398
|
+
) : null}
|
|
399
|
+
</View>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const styles = StyleSheet.create({
|
|
404
|
+
srOnly: {
|
|
405
|
+
position: "absolute",
|
|
406
|
+
width: 1,
|
|
407
|
+
height: 1,
|
|
408
|
+
opacity: 0,
|
|
409
|
+
},
|
|
410
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Pressable, Text, View } from "react-native";
|
|
2
|
+
import { cn } from "../cn";
|
|
3
|
+
import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
|
|
4
|
+
import { tokens } from "../tokens";
|
|
5
|
+
import { useLargerText } from "../theme";
|
|
6
|
+
import { useScheme } from "../useScheme";
|
|
7
|
+
|
|
8
|
+
/** Web `rounded-2xl` (16) on the card. */
|
|
9
|
+
const RADIUS = tokens.radius.lg;
|
|
10
|
+
|
|
11
|
+
// The web `Button` `sm` (`h-8 px-3 text-xs`) uses `max-sm:hit-target`, 8 on
|
|
12
|
+
// each side: 32 + 16 = 48, as the native `Button` `small`.
|
|
13
|
+
const HIT_SLOP = 8;
|
|
14
|
+
|
|
15
|
+
export type UpgradeToProCardProps = {
|
|
16
|
+
/** Opens the app's `PricingModal`. */
|
|
17
|
+
onUpgrade: () => void;
|
|
18
|
+
/**
|
|
19
|
+
* A "Pro" pill (labelled with `title`) instead of the full card. Like the
|
|
20
|
+
* web `Button` it stretches across a column and fits its label in a row.
|
|
21
|
+
* Default `false`.
|
|
22
|
+
*/
|
|
23
|
+
collapsed?: boolean;
|
|
24
|
+
/** Default "Upgrade to Pro". */
|
|
25
|
+
title?: string;
|
|
26
|
+
/** Default "Upgrade". */
|
|
27
|
+
ctaLabel?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
testID?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upgrade prompt, the native `SidebarUpgradeCard`: the title and an "Upgrade"
|
|
34
|
+
* pill on a secondary surface, with no mark. It does not check the plan; the
|
|
35
|
+
* app hides it when the user is already on Pro.
|
|
36
|
+
*/
|
|
37
|
+
export function UpgradeToProCard({
|
|
38
|
+
onUpgrade,
|
|
39
|
+
collapsed = false,
|
|
40
|
+
title = "Upgrade to Pro",
|
|
41
|
+
ctaLabel = "Upgrade",
|
|
42
|
+
className,
|
|
43
|
+
testID,
|
|
44
|
+
}: UpgradeToProCardProps) {
|
|
45
|
+
const scheme = useScheme();
|
|
46
|
+
const largerText = useLargerText();
|
|
47
|
+
// Each class is also set as a style (`sizeStyle`): web `text-sm` is 14 / 20.
|
|
48
|
+
const titleSize = largerText ? "text-lg" : "text-sm";
|
|
49
|
+
const ctaSize = largerText ? "text-base" : "text-xs";
|
|
50
|
+
// The web pill is a `Button` `sm` (`font-medium`) in `bg-foreground text-background`.
|
|
51
|
+
const pill = (label: string, accessibilityLabel?: string) => (
|
|
52
|
+
<Pressable
|
|
53
|
+
testID={testID ? `${testID}-cta` : undefined}
|
|
54
|
+
accessibilityRole="button"
|
|
55
|
+
accessibilityLabel={accessibilityLabel}
|
|
56
|
+
onPress={onUpgrade}
|
|
57
|
+
hitSlop={HIT_SLOP}
|
|
58
|
+
className={cn(
|
|
59
|
+
"shrink-0 items-center justify-center rounded-full active:opacity-80",
|
|
60
|
+
largerText ? "min-h-12 px-4" : "min-h-8 px-3",
|
|
61
|
+
)}
|
|
62
|
+
style={{ backgroundColor: tokens.color.foreground[scheme] }}
|
|
63
|
+
>
|
|
64
|
+
<Text
|
|
65
|
+
className={cn("font-instrument-sans-medium", ctaSize)}
|
|
66
|
+
style={[
|
|
67
|
+
{ color: tokens.color.background[scheme] },
|
|
68
|
+
sizeStyle(ctaSize),
|
|
69
|
+
typeStyle("medium"),
|
|
70
|
+
]}
|
|
71
|
+
>
|
|
72
|
+
{label}
|
|
73
|
+
</Text>
|
|
74
|
+
</Pressable>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (collapsed) {
|
|
78
|
+
return (
|
|
79
|
+
<View testID={testID} className={className}>
|
|
80
|
+
{pill("Pro", title)}
|
|
81
|
+
</View>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return (
|
|
85
|
+
<View
|
|
86
|
+
testID={testID}
|
|
87
|
+
className={cn(
|
|
88
|
+
"flex-row items-center justify-between gap-3 bg-surface-secondary px-3 py-2.5",
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
style={{ borderRadius: RADIUS }}
|
|
92
|
+
>
|
|
93
|
+
<Text
|
|
94
|
+
numberOfLines={1}
|
|
95
|
+
className={cn("min-w-0 flex-1 font-instrument-sans-semibold", titleSize)}
|
|
96
|
+
style={[textStyle("foreground", scheme), sizeStyle(titleSize), typeStyle("semibold")]}
|
|
97
|
+
>
|
|
98
|
+
{title}
|
|
99
|
+
</Text>
|
|
100
|
+
{pill(ctaLabel)}
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|