@vuoma/react-native-ui 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 ADDED
@@ -0,0 +1,45 @@
1
+ Vuoma Connected Application License
2
+ Version 1.0 — October 19, 2026
3
+
4
+ Copyright (c) 2026 UPPP LLC, doing business as Vuoma.
5
+
6
+ Permission is granted, without a separate software license fee, to use, copy,
7
+ modify and distribute this package and its documentation (the Software) solely
8
+ to develop, test, operate and distribute applications that integrate with the
9
+ Vuoma service through authorized Vuoma APIs. This includes commercial,
10
+ studio-branded applications, websites, and development or test versions using
11
+ mocked Vuoma responses. You may share the Software with your developers and
12
+ contractors for these purposes and include it in source or compiled application
13
+ distributions, subject to this license.
14
+
15
+ You must retain this license and the copyright notice in copies or substantial
16
+ portions of the Software, including an application's third-party notices. Your
17
+ modifications must remain subject to these permitted-use limits. You may not
18
+ use or distribute the Software as a general-purpose library for applications
19
+ unconnected to Vuoma, or to implement or operate a replacement for the Vuoma
20
+ service, without separate written permission from UPPP LLC.
21
+
22
+ This license applies only to the Software included in this package. It does
23
+ not license Vuoma's backend, databases, customer data, studio branding,
24
+ third-party assets or trademarks. Separately identified third-party software
25
+ remains subject to its own license. You retain any rights you otherwise hold
26
+ in your application code and content.
27
+
28
+ This license does not grant API credentials, access to any account or data,
29
+ hosting, support, or a right to continued service. Access to Vuoma remains
30
+ subject to applicable service agreements, permissions and charges. Ending
31
+ service does not transfer the backend or authorize continued API access. You
32
+ may retain archival copies and use the Software in development intended for an
33
+ authorized Vuoma integration; operating it for an unrelated replacement service
34
+ requires separate permission.
35
+
36
+ All rights not expressly granted are reserved. Use outside these terms is not
37
+ licensed. This is a source-available, restricted-use license, not an open-source
38
+ license. Separate written agreements with UPPP LLC may grant additional rights.
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
42
+ NONINFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER
43
+ IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM THE
44
+ SOFTWARE OR ITS USE. Nothing in this license limits liability or rights that
45
+ cannot lawfully be limited.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ # @vuoma/react-native-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Controlled React Native account and booking UI used by Vuoma's own apps. The
4
+ same package is available for independently developed Vuoma-connected apps.
5
+ It has no studio artwork, fonts, tenant configuration, credentials, navigation,
6
+ network requests or storage. React and React Native are its only runtime peers. This is the renamed
7
+ `@vuoma/native-ui` package with the same controlled component API; change
8
+ existing import prefixes and dependencies together. Its first publication
9
+ under the new name must be verified before registry installation. Verification
10
+ forms preserve the complete six- or eight-digit code, including leading zeroes.
11
+
12
+ ```sh
13
+ npm install @vuoma/react-native-ui@0.1.0 @vuoma/sdk@0.2.1
14
+ ```
15
+
16
+ Use React 19.3 and React Native 0.88 or compatible versions in the declared peer
17
+ ranges. Metro and Expo web consume the included TypeScript source; other ESM
18
+ bundlers consume compiled JavaScript. Type declarations are included. Load any
19
+ licensed custom fonts in the host before supplying typography styles.
20
+
21
+ ## Sign in with the public SDK
22
+
23
+ Supply the client created for the selected studio and an `acceptSession`
24
+ callback owned by your app's secure-session provider. That callback must store
25
+ native credentials in platform secure storage and expose the current member
26
+ session to the SDK's `tokenAdapter`. The UI never receives those credentials.
27
+
28
+ ```tsx
29
+ import { useEffect, useRef, useState } from 'react'
30
+ import { KeyboardAvoidingView, Platform, ScrollView, Text } from 'react-native'
31
+ import { AuthContent, SignInForm, type NativeTheme } from '@vuoma/react-native-ui'
32
+ import type { MemberAuthSession, VuomaClient } from '@vuoma/sdk'
33
+
34
+ export function StudioSignIn({ client, theme, acceptSession }: {
35
+ client: VuomaClient
36
+ theme: NativeTheme
37
+ acceptSession: (session: MemberAuthSession, signal: AbortSignal) => Promise<void>
38
+ }) {
39
+ const [email, setEmail] = useState('')
40
+ const [password, setPassword] = useState('')
41
+ const [passwordVisible, setPasswordVisible] = useState(false)
42
+ const [busy, setBusy] = useState(false)
43
+ const [message, setMessage] = useState<string | null>(null)
44
+ const request = useRef<AbortController | null>(null)
45
+ useEffect(() => () => request.current?.abort(), [])
46
+ const ready = email.trim().includes('@') && password.length > 0 && !busy
47
+
48
+ async function submit() {
49
+ if (!ready || request.current) return
50
+ const controller = new AbortController()
51
+ request.current = controller
52
+ setBusy(true)
53
+ setMessage(null)
54
+ let issued: MemberAuthSession | undefined
55
+ try {
56
+ issued = await client.auth.signIn({ email: email.trim(), password, signal: controller.signal })
57
+ await acceptSession(issued, controller.signal)
58
+ } catch {
59
+ if (issued) {
60
+ // Revoke credentials if this device could not securely accept them.
61
+ void client.auth.signOut({ refreshToken: issued.refresh_token }).catch(() => {})
62
+ }
63
+ if (!controller.signal.aborted) setMessage('Sign-in could not be completed. Please try again.')
64
+ } finally {
65
+ if (request.current === controller) request.current = null
66
+ if (!controller.signal.aborted) { setPassword(''); setBusy(false) }
67
+ }
68
+ }
69
+
70
+ return <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
71
+ <ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }}>
72
+ <AuthContent theme={theme} branding={<Text>Example studio</Text>}>
73
+ <SignInForm theme={theme} email={email} password={password}
74
+ passwordVisible={passwordVisible} onPasswordVisibleChange={setPasswordVisible}
75
+ onEmailChange={setEmail} onPasswordChange={setPassword}
76
+ canSubmit={ready} busy={busy} message={message} onSubmit={() => void submit()} />
77
+ </AuthContent>
78
+ </ScrollView>
79
+ </KeyboardAvoidingView>
80
+ }
81
+ ```
82
+
83
+ Wrap your screen in the host's safe-area handling. Clear inputs and cancel
84
+ pending requests on account/studio changes and navigation. Browser deployments
85
+ need a same-origin server session adapter with secure HttpOnly cookies; do not
86
+ copy native token storage into browser local storage.
87
+
88
+ ## Account setup and booking
89
+
90
+ All forms use controlled values, explicit submit callbacks and `canSubmit` /
91
+ `busy` state. Busy forms lock submission, editing and auxiliary actions. Errors
92
+ use a live accessible alert. The host supplies useful public error messages;
93
+ never display raw provider errors or tokens.
94
+
95
+ | Component | Host-owned API flow |
96
+ | --- | --- |
97
+ | `AccountDetailsForm` | Explicit `client.auth.requestAccountCode`, with signup/recovery intent and optional name |
98
+ | `VerificationCodeForm` | `client.auth.confirmAccountCode`; keep its returned password capability in memory |
99
+ | `NewPasswordForm` | Enforce password policy, then `client.auth.completePassword`; clear capability/code/password after completion or cancellation |
100
+ | `BookingReview` | Display authoritative details, then explicitly call `client.member.bookClass` or `bookAppointmentVisit` |
101
+ | `BookingReviewHeader` | Shared heading for a host-owned booking sheet with a separate fixed confirmation action |
102
+
103
+ Supply `inputRef` to verification and new-password forms when moving keyboard
104
+ focus between steps. Mount each step separately; the email-code field uses
105
+ numeric one-time-code autofill and the password field uses the platform's
106
+ secure new-password autofill. Name is omitted from `AccountDetailsForm` when
107
+ `name` or `onNameChange` is omitted. Use `AuthLink` for resend, recovery and
108
+ navigation controls, `AuthButton` for a separate sheet action, and `AuthMessage`
109
+ for host-owned setup errors.
110
+
111
+ `BookingReview` accepts `title`, `description`, optional `{label, value}`
112
+ details, a `children` slot for your policy/benefit presentation, optional
113
+ `message`, and `action: {label, onPress, disabled?, busy?}`. Render a success
114
+ title or icon only after an authoritative receipt. Availability, credits,
115
+ prices, waiver requirements and payment authorization remain API-owned.
116
+ Retain the exact booking request and request ID after an uncertain response;
117
+ check the original result before offering another booking. Do not retry with a
118
+ new request ID or claim that a timeout means no reservation was created.
119
+
120
+ This package is member UI, not a staff/admin application. It does not include
121
+ payment-card collection, legal waiver signing, the platform account provider,
122
+ background notifications or automatic navigation. Those integrations remain
123
+ the host's explicit responsibility and must preserve the API's authorization
124
+ and consent requirements.
125
+
126
+ ## Theme and branding
127
+
128
+ Pass `theme.colors` containing `text`, `muted`, `card`, `border`, `primary` and
129
+ `primary_contrast`. Optional `typography.body`, `typography.label` and
130
+ `typography.heading` accept React Native text styles. `controlRadius` and
131
+ `controlBorderWidth` customize controls. `AuthContent.branding` accepts your
132
+ own transparent mark or full wordmark. Omit `title` and `description` for the
133
+ simple brand-and-form layout. Password visibility uses a textual accessible
134
+ fallback; supply `renderPasswordVisibilityIcon` for your icon library.
135
+
136
+ ## Verification and license
137
+
138
+ `npm run verify` builds declarations, checks actual component interactions,
139
+ packs the package and typechecks/renders an isolated consumer. The Vuoma native
140
+ apps consume these same exports for member and platform sign-in, account
141
+ setup, appointment review/receipts and class booking confirmation.
142
+
143
+ The [Vuoma Connected Application License](./LICENSE) allows using, modifying
144
+ and distributing this package for authorized Vuoma-connected apps, including
145
+ commercial studio apps and mock-based development. It is source-available
146
+ under a restricted-use license, not an open-source or general-purpose license.
147
+ Studio branding, fonts, Vuoma's backend and customer data are not included.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { type PropsWithChildren, type ReactNode, type Ref } from "react";
2
+ import { type StyleProp, type TextInputInstance, type ViewStyle } from "react-native";
3
+ import type { NativeTheme } from "./theme.ts";
4
+ export interface AuthContentProps extends PropsWithChildren {
5
+ theme: NativeTheme;
6
+ branding?: ReactNode;
7
+ title?: string;
8
+ description?: string;
9
+ descriptionAlign?: "left" | "center";
10
+ footer?: ReactNode;
11
+ style?: StyleProp<ViewStyle>;
12
+ }
13
+ /** Compose inside the host's safe-area scroll view and keyboard-avoiding shell. */
14
+ export declare function AuthContent({ theme, branding, title, description, descriptionAlign, footer, children, style }: AuthContentProps): import("react").JSX.Element;
15
+ export interface AuthButtonProps {
16
+ theme: NativeTheme;
17
+ label: string;
18
+ busy?: boolean;
19
+ disabled?: boolean;
20
+ onPress: () => void;
21
+ }
22
+ export declare function AuthButton({ theme, label, busy, disabled, onPress }: AuthButtonProps): import("react").JSX.Element;
23
+ export interface AuthLinkProps {
24
+ theme: NativeTheme;
25
+ label: string;
26
+ accessibilityLabel?: string;
27
+ disabled?: boolean;
28
+ align?: "center" | "right";
29
+ onPress: () => void;
30
+ }
31
+ export declare function AuthLink({ theme, label, accessibilityLabel, disabled, align, onPress }: AuthLinkProps): import("react").JSX.Element;
32
+ export declare function AuthMessage({ theme, children }: {
33
+ theme: NativeTheme;
34
+ children?: string | null;
35
+ }): import("react").JSX.Element | null;
36
+ export interface SignInFormProps {
37
+ theme: NativeTheme;
38
+ email: string;
39
+ password: string;
40
+ passwordVisible: boolean;
41
+ onEmailChange: (value: string) => void;
42
+ onPasswordChange: (value: string) => void;
43
+ onPasswordVisibleChange: (visible: boolean) => void;
44
+ onSubmit: () => void;
45
+ /** Host validation and policy determine readiness; the component grants no access. */
46
+ canSubmit: boolean;
47
+ busy?: boolean;
48
+ message?: string | null;
49
+ emailLabel?: string;
50
+ passwordPlaceholder?: string;
51
+ renderPasswordVisibilityIcon?: (visible: boolean) => ReactNode;
52
+ passwordHelp?: ReactNode;
53
+ footer?: ReactNode;
54
+ }
55
+ export declare function SignInForm({ theme, email, password, passwordVisible, onEmailChange, onPasswordChange, onPasswordVisibleChange, onSubmit, canSubmit, busy, message, emailLabel, passwordPlaceholder, renderPasswordVisibilityIcon, passwordHelp, footer }: SignInFormProps): import("react").JSX.Element;
56
+ interface SubmitFormProps {
57
+ theme: NativeTheme;
58
+ busy?: boolean;
59
+ canSubmit: boolean;
60
+ onSubmit: () => void;
61
+ message?: string | null;
62
+ }
63
+ export interface AccountDetailsFormProps extends SubmitFormProps {
64
+ email: string;
65
+ onEmailChange: (value: string) => void;
66
+ /** Omit for existing-profile activation or password recovery. */
67
+ name?: string;
68
+ onNameChange?: (value: string) => void;
69
+ emailInputRef?: Ref<TextInputInstance>;
70
+ }
71
+ export declare function AccountDetailsForm({ theme, email, onEmailChange, name, onNameChange, emailInputRef, busy, canSubmit, onSubmit, message }: AccountDetailsFormProps): import("react").JSX.Element;
72
+ export interface VerificationCodeFormProps extends SubmitFormProps {
73
+ code: string;
74
+ onCodeChange: (value: string) => void;
75
+ inputRef?: Ref<TextInputInstance>;
76
+ onResend?: () => void;
77
+ onEditEmail?: () => void;
78
+ }
79
+ export declare function VerificationCodeForm({ theme, code, onCodeChange, inputRef, busy, canSubmit, onSubmit, onResend, onEditEmail, message }: VerificationCodeFormProps): import("react").JSX.Element;
80
+ export interface NewPasswordFormProps extends SubmitFormProps {
81
+ password: string;
82
+ onPasswordChange: (value: string) => void;
83
+ inputRef?: Ref<TextInputInstance>;
84
+ /** Native autofill guidance only. The host and API still enforce password policy. */
85
+ passwordRules?: string;
86
+ onRestart?: () => void;
87
+ }
88
+ export declare function NewPasswordForm({ theme, password, onPasswordChange, inputRef, passwordRules, busy, canSubmit, onSubmit, onRestart, message }: NewPasswordFormProps): import("react").JSX.Element;
89
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { ActivityIndicator, Platform, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
4
+ /** Compose inside the host's safe-area scroll view and keyboard-avoiding shell. */
5
+ export function AuthContent({ theme, branding, title, description, descriptionAlign = "center", footer, children, style }) {
6
+ return _jsxs(View, { style: [styles.content, style], children: [branding, title || description ? _jsxs(View, { style: styles.intro, children: [title ? _jsx(Text, { accessibilityRole: "header", style: [styles.title, theme.typography?.heading, { color: theme.colors.text }], children: title }) : null, description ? _jsx(Text, { style: [styles.description, theme.typography?.body, { color: theme.colors.muted, textAlign: descriptionAlign }], children: description }) : null] }) : null, _jsx(View, { style: styles.form, children: children }), footer] });
7
+ }
8
+ export function AuthButton({ theme, label, busy = false, disabled = false, onPress }) {
9
+ const locked = busy || disabled;
10
+ return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, accessibilityState: { disabled: locked, busy }, disabled: locked, onPress: () => { if (!locked)
11
+ onPress(); }, style: ({ pressed }) => [styles.button, { backgroundColor: theme.colors.primary, borderRadius: theme.controlRadius ?? 12, opacity: locked ? 0.42 : pressed ? 0.8 : 1 }], children: busy ? _jsx(ActivityIndicator, { accessible: false, color: theme.colors.primary_contrast }) : _jsx(Text, { style: [styles.button_label, theme.typography?.label, { color: theme.colors.primary_contrast }], children: label }) });
12
+ }
13
+ export function AuthLink({ theme, label, accessibilityLabel = label, disabled = false, align = "center", onPress }) {
14
+ return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel, accessibilityState: { disabled }, disabled: disabled, onPress: () => { if (!disabled)
15
+ onPress(); }, style: ({ pressed }) => [styles.link, align === "right" ? styles.right : null, { opacity: disabled ? 0.5 : pressed ? 0.6 : 1 }], children: _jsx(Text, { style: [styles.link_text, theme.typography?.label, { color: theme.colors.text }], children: label }) });
16
+ }
17
+ export function AuthMessage({ theme, children }) {
18
+ return children ? _jsx(Text, { accessibilityLiveRegion: "polite", accessibilityRole: "alert", style: [styles.message, theme.typography?.body, { color: theme.colors.text }], children: children }) : null;
19
+ }
20
+ function Field({ theme, label, children }) {
21
+ return _jsxs(View, { style: styles.field, children: [_jsx(Text, { style: [styles.label, theme.typography?.label, { color: theme.colors.text }], children: label }), children] });
22
+ }
23
+ function inputStyle(theme) {
24
+ return [styles.input, theme.typography?.body, { backgroundColor: theme.colors.card, borderColor: theme.colors.border, color: theme.colors.text, borderRadius: theme.controlRadius ?? 12, borderWidth: theme.controlBorderWidth ?? StyleSheet.hairlineWidth }];
25
+ }
26
+ export function SignInForm({ theme, email, password, passwordVisible, onEmailChange, onPasswordChange, onPasswordVisibleChange, onSubmit, canSubmit, busy = false, message, emailLabel = "Email", passwordPlaceholder = "Your password", renderPasswordVisibilityIcon, passwordHelp, footer }) {
27
+ const passwordInput = useRef(null);
28
+ const submit = () => { if (canSubmit && !busy)
29
+ onSubmit(); };
30
+ return _jsxs(View, { style: styles.form, children: [_jsx(Field, { theme: theme, label: emailLabel, children: _jsx(TextInput, { accessibilityLabel: emailLabel, autoCapitalize: "none", autoComplete: "email", autoCorrect: false, blurOnSubmit: false, editable: !busy, inputMode: "email", keyboardType: "email-address", maxLength: 320, onChangeText: value => { if (!busy)
31
+ onEmailChange(value); }, onSubmitEditing: () => { if (!busy)
32
+ passwordInput.current?.focus(); }, placeholder: "you@example.com", placeholderTextColor: theme.colors.muted, returnKeyType: "next", style: inputStyle(theme), textContentType: "username", value: email }) }), _jsxs(Field, { theme: theme, label: "Password", children: [_jsxs(View, { style: [styles.password_field, { backgroundColor: theme.colors.card, borderColor: theme.colors.border, borderRadius: theme.controlRadius ?? 12, borderWidth: theme.controlBorderWidth ?? StyleSheet.hairlineWidth }], children: [_jsx(TextInput, { accessibilityLabel: "Password", autoCapitalize: "none", autoComplete: "current-password", autoCorrect: false, editable: !busy, maxLength: 1_024, onChangeText: value => { if (!busy)
33
+ onPasswordChange(value); }, onSubmitEditing: submit, placeholder: passwordPlaceholder, placeholderTextColor: theme.colors.muted, ref: passwordInput, returnKeyType: "go", secureTextEntry: !passwordVisible, style: [styles.password_input, theme.typography?.body, { color: theme.colors.text }], textContentType: "password", value: password }), _jsx(Pressable, { accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", accessibilityState: { disabled: busy }, disabled: busy, onPress: () => { if (!busy)
34
+ onPasswordVisibleChange(!passwordVisible); }, style: ({ pressed }) => [styles.password_toggle, { opacity: pressed ? 0.6 : 1 }], children: renderPasswordVisibilityIcon ? renderPasswordVisibilityIcon(passwordVisible) : _jsx(Text, { style: [styles.link_text, { color: theme.colors.muted }], children: passwordVisible ? "Hide" : "Show" }) })] }), passwordHelp] }), _jsx(AuthMessage, { theme: theme, children: message }), _jsx(AuthButton, { theme: theme, disabled: !canSubmit, busy: busy, label: busy ? "Signing in…" : "Sign in", onPress: submit }), footer] });
35
+ }
36
+ export function AccountDetailsForm({ theme, email, onEmailChange, name, onNameChange, emailInputRef, busy = false, canSubmit, onSubmit, message }) {
37
+ const localEmailRef = useRef(null);
38
+ const submit = () => { if (canSubmit && !busy)
39
+ onSubmit(); };
40
+ return _jsxs(View, { style: styles.form, children: [name != null && onNameChange ? _jsx(Field, { theme: theme, label: "Name", children: _jsx(TextInput, { accessibilityLabel: "Name", autoComplete: "name", textContentType: "name", editable: !busy, maxLength: 160, onChangeText: value => { if (!busy)
41
+ onNameChange(value); }, onSubmitEditing: () => { if (!busy)
42
+ localEmailRef.current?.focus(); }, returnKeyType: "next", style: inputStyle(theme), value: name }) }) : null, _jsx(Field, { theme: theme, label: "Email", children: _jsx(TextInput, { accessibilityLabel: "Email", autoCapitalize: "none", autoComplete: "email", autoCorrect: false, editable: !busy, inputMode: "email", keyboardType: "email-address", maxLength: 320, onChangeText: value => { if (!busy)
43
+ onEmailChange(value); }, onSubmitEditing: submit, placeholder: "you@example.com", placeholderTextColor: theme.colors.muted, ref: value => { localEmailRef.current = value; if (typeof emailInputRef === "function")
44
+ emailInputRef(value);
45
+ else if (emailInputRef)
46
+ emailInputRef.current = value; }, returnKeyType: "go", style: inputStyle(theme), textContentType: "emailAddress", value: email }) }), _jsx(AuthMessage, { theme: theme, children: message }), _jsx(AuthButton, { theme: theme, disabled: !canSubmit, busy: busy, label: busy ? "Sending code…" : "Send code", onPress: submit })] });
47
+ }
48
+ export function VerificationCodeForm({ theme, code, onCodeChange, inputRef, busy = false, canSubmit, onSubmit, onResend, onEditEmail, message }) {
49
+ const ready = canSubmit && /^(?:\d{6}|\d{8})$/u.test(code);
50
+ const submit = () => { if (ready && !busy)
51
+ onSubmit(); };
52
+ return _jsxs(View, { style: styles.form, children: [_jsx(Field, { theme: theme, label: "Email code", children: _jsx(TextInput, { ref: inputRef, accessibilityLabel: "Email code", autoComplete: "one-time-code", autoCorrect: false, editable: !busy, inputMode: "numeric", keyboardType: "number-pad", maxLength: 8, onChangeText: value => { if (!busy)
53
+ onCodeChange(value.replace(/\D/gu, "").slice(0, 8)); }, onSubmitEditing: submit, returnKeyType: "go", style: [inputStyle(theme), styles.code], textContentType: "oneTimeCode", value: code }, "email-code") }), _jsx(AuthMessage, { theme: theme, children: message }), _jsx(AuthButton, { theme: theme, disabled: !ready, busy: busy, label: busy ? "Checking code…" : "Verify code", onPress: submit }), onResend ? _jsx(AuthLink, { theme: theme, disabled: busy, label: "Send a new code", onPress: onResend }) : null, onEditEmail ? _jsx(AuthLink, { theme: theme, disabled: busy, label: "Use a different email", onPress: onEditEmail }) : null] });
54
+ }
55
+ export function NewPasswordForm({ theme, password, onPasswordChange, inputRef, passwordRules, busy = false, canSubmit, onSubmit, onRestart, message }) {
56
+ const submit = () => { if (canSubmit && !busy)
57
+ onSubmit(); };
58
+ return _jsxs(View, { style: styles.form, children: [_jsx(Field, { theme: theme, label: "New password", children: _jsx(TextInput, { ref: inputRef, accessibilityLabel: "New password", inputMode: "text", keyboardType: "default", showSoftInputOnFocus: true, importantForAutofill: "yes", autoCapitalize: "none", autoComplete: Platform.OS === "ios" ? undefined : "new-password", autoCorrect: false, editable: !busy, maxLength: 1_024, onChangeText: value => { if (!busy)
59
+ onPasswordChange(value); }, onSubmitEditing: submit, passwordRules: passwordRules, returnKeyType: "go", secureTextEntry: true, style: inputStyle(theme), textContentType: Platform.OS === "ios" ? "newPassword" : undefined, value: password }, "new-password") }), _jsx(AuthMessage, { theme: theme, children: message }), _jsx(AuthButton, { theme: theme, disabled: !canSubmit, busy: busy, label: busy ? "Saving password…" : "Save password", onPress: submit }), onRestart ? _jsx(AuthLink, { theme: theme, disabled: busy, label: "Start again with a new code", onPress: onRestart }) : null] });
60
+ }
61
+ const styles = StyleSheet.create({
62
+ content: { width: "100%", maxWidth: 400, paddingVertical: 28, gap: 32 },
63
+ form: { gap: 20 }, field: { gap: 8 }, label: { fontSize: 13, fontWeight: "500" },
64
+ intro: { gap: 10, alignItems: "center" },
65
+ title: { fontSize: 30, lineHeight: 37, fontWeight: "600", letterSpacing: -0.8, textAlign: "center" },
66
+ description: { fontSize: 15, lineHeight: 22, alignSelf: "stretch" },
67
+ input: { minHeight: 56, paddingHorizontal: 16, fontSize: 16 },
68
+ button: { minHeight: 54, alignItems: "center", justifyContent: "center", paddingHorizontal: 18, marginTop: 4 },
69
+ button_label: { fontSize: 16, fontWeight: "600" },
70
+ link: { minHeight: 44, justifyContent: "center", alignItems: "center" }, right: { alignSelf: "flex-end" },
71
+ link_text: { fontSize: 14, fontWeight: "500", textAlign: "center" },
72
+ message: { fontSize: 14, lineHeight: 20 },
73
+ password_field: { minHeight: 56, flexDirection: "row", alignItems: "center" },
74
+ password_input: { minHeight: 54, flex: 1, paddingHorizontal: 16, fontSize: 16 },
75
+ password_toggle: { minWidth: 48, minHeight: 54, alignItems: "center", justifyContent: "center", paddingHorizontal: 8 },
76
+ code: { fontSize: 24, letterSpacing: 8 },
77
+ });
@@ -0,0 +1,21 @@
1
+ import type { PropsWithChildren, ReactNode } from "react";
2
+ import { type AuthButtonProps } from "./auth.tsx";
3
+ import type { NativeTheme } from "./theme.ts";
4
+ export interface BookingReviewProps extends PropsWithChildren {
5
+ theme: NativeTheme;
6
+ title: string;
7
+ description?: string;
8
+ /** Render a success symbol only after the API has confirmed the booking. */
9
+ statusIcon?: ReactNode;
10
+ details?: ReadonlyArray<{
11
+ label: string;
12
+ value: string;
13
+ }>;
14
+ message?: string | null;
15
+ action?: Omit<AuthButtonProps, "theme">;
16
+ footer?: ReactNode;
17
+ }
18
+ export type BookingReviewHeaderProps = Pick<BookingReviewProps, "theme" | "title" | "description" | "statusIcon">;
19
+ export declare function BookingReviewHeader({ theme, title, description, statusIcon }: BookingReviewHeaderProps): import("react").JSX.Element;
20
+ /** Review and receipt presentation. The host owns availability, policy and payment. */
21
+ export declare function BookingReview({ theme, title, description, statusIcon, details, message, action, footer, children }: BookingReviewProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+ import { AuthButton, AuthMessage } from "./auth.js";
4
+ export function BookingReviewHeader({ theme, title, description, statusIcon }) {
5
+ return _jsxs(View, { style: styles.heading, children: [statusIcon, _jsx(Text, { accessibilityRole: "header", style: [styles.title, theme.typography?.heading, { color: theme.colors.text }], children: title }), description ? _jsx(Text, { style: [styles.description, theme.typography?.body, { color: theme.colors.muted }], children: description }) : null] });
6
+ }
7
+ /** Review and receipt presentation. The host owns availability, policy and payment. */
8
+ export function BookingReview({ theme, title, description, statusIcon, details, message, action, footer, children }) {
9
+ return _jsxs(View, { style: styles.review, children: [_jsx(BookingReviewHeader, { theme: theme, title: title, description: description, statusIcon: statusIcon }), details?.length ? _jsx(View, { style: [styles.details, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }], children: details.map((detail, index) => _jsxs(View, { style: styles.detail, children: [_jsx(Text, { style: [styles.detail_label, theme.typography?.label, { color: theme.colors.muted }], children: detail.label }), _jsx(Text, { style: [styles.detail_value, theme.typography?.body, { color: theme.colors.text }], children: detail.value })] }, `${index}:${detail.label}`)) }) : null, children, _jsx(AuthMessage, { theme: theme, children: message }), action ? _jsx(AuthButton, { ...action, theme: theme }) : null, footer] });
10
+ }
11
+ const styles = StyleSheet.create({
12
+ review: { gap: 20 }, heading: { gap: 8 },
13
+ title: { fontSize: 25, lineHeight: 31, fontWeight: "700" },
14
+ description: { fontSize: 16, lineHeight: 23 },
15
+ details: { gap: 14, padding: 18, borderWidth: StyleSheet.hairlineWidth, borderRadius: 16 },
16
+ detail: { gap: 4 }, detail_label: { fontSize: 14, lineHeight: 20 }, detail_value: { fontSize: 16, lineHeight: 23 },
17
+ });
@@ -0,0 +1,3 @@
1
+ export * from "./auth.tsx";
2
+ export * from "./booking.tsx";
3
+ export type { NativeTheme } from "./theme.ts";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./auth.js";
2
+ export * from "./booking.js";
@@ -0,0 +1,19 @@
1
+ import type { TextStyle } from "react-native";
2
+ /** Fonts, artwork and colors are supplied by the host, never fetched by the UI. */
3
+ export interface NativeTheme {
4
+ colors: {
5
+ text: string;
6
+ muted: string;
7
+ card: string;
8
+ border: string;
9
+ primary: string;
10
+ primary_contrast: string;
11
+ };
12
+ typography?: {
13
+ body?: TextStyle;
14
+ label?: TextStyle;
15
+ heading?: TextStyle;
16
+ };
17
+ controlRadius?: number;
18
+ controlBorderWidth?: number;
19
+ }
package/dist/theme.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@vuoma/react-native-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Controlled React Native authentication and booking components used by Vuoma apps",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "files": [
8
+ "dist",
9
+ "src",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "react-native": "./src/index.ts",
17
+ "browser": "./src/index.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "types": "./dist/index.d.ts",
22
+ "sideEffects": false,
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "npm run build && node --test tests/*.test.mjs",
26
+ "verify": "npm test && node scripts/verify-package.mjs",
27
+ "prepack": "npm run build",
28
+ "prepare": "npm run build"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=19.3.0 <20",
35
+ "react-native": ">=0.88.0 <1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/react": "~19.3.0",
39
+ "typescript": "~6.0.3"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }
package/src/auth.tsx ADDED
@@ -0,0 +1,221 @@
1
+ import { useRef, type PropsWithChildren, type ReactNode, type Ref } from "react"
2
+ import { ActivityIndicator, Platform, Pressable, StyleSheet, Text, TextInput, View, type StyleProp, type TextInputInstance, type ViewStyle } from "react-native"
3
+ import type { NativeTheme } from "./theme.ts"
4
+
5
+ export interface AuthContentProps extends PropsWithChildren {
6
+ theme: NativeTheme
7
+ branding?: ReactNode
8
+ title?: string
9
+ description?: string
10
+ descriptionAlign?: "left" | "center"
11
+ footer?: ReactNode
12
+ style?: StyleProp<ViewStyle>
13
+ }
14
+
15
+ /** Compose inside the host's safe-area scroll view and keyboard-avoiding shell. */
16
+ export function AuthContent({ theme, branding, title, description, descriptionAlign = "center", footer, children, style }: AuthContentProps) {
17
+ return <View style={[styles.content, style]}>
18
+ {branding}
19
+ {title || description ? <View style={styles.intro}>
20
+ {title ? <Text accessibilityRole="header" style={[styles.title, theme.typography?.heading, { color: theme.colors.text }]}>{title}</Text> : null}
21
+ {description ? <Text style={[styles.description, theme.typography?.body, { color: theme.colors.muted, textAlign: descriptionAlign }]}>{description}</Text> : null}
22
+ </View> : null}
23
+ <View style={styles.form}>{children}</View>
24
+ {footer}
25
+ </View>
26
+ }
27
+
28
+ export interface AuthButtonProps {
29
+ theme: NativeTheme
30
+ label: string
31
+ busy?: boolean
32
+ disabled?: boolean
33
+ onPress: () => void
34
+ }
35
+
36
+ export function AuthButton({ theme, label, busy = false, disabled = false, onPress }: AuthButtonProps) {
37
+ const locked = busy || disabled
38
+ return <Pressable accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled: locked, busy }} disabled={locked}
39
+ onPress={() => { if (!locked) onPress() }}
40
+ style={({ pressed }) => [styles.button, { backgroundColor: theme.colors.primary, borderRadius: theme.controlRadius ?? 12, opacity: locked ? 0.42 : pressed ? 0.8 : 1 }]}>
41
+ {busy ? <ActivityIndicator accessible={false} color={theme.colors.primary_contrast} /> : <Text style={[styles.button_label, theme.typography?.label, { color: theme.colors.primary_contrast }]}>{label}</Text>}
42
+ </Pressable>
43
+ }
44
+
45
+ export interface AuthLinkProps {
46
+ theme: NativeTheme
47
+ label: string
48
+ accessibilityLabel?: string
49
+ disabled?: boolean
50
+ align?: "center" | "right"
51
+ onPress: () => void
52
+ }
53
+
54
+ export function AuthLink({ theme, label, accessibilityLabel = label, disabled = false, align = "center", onPress }: AuthLinkProps) {
55
+ return <Pressable accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityState={{ disabled }} disabled={disabled}
56
+ onPress={() => { if (!disabled) onPress() }} style={({ pressed }) => [styles.link, align === "right" ? styles.right : null, { opacity: disabled ? 0.5 : pressed ? 0.6 : 1 }]}>
57
+ <Text style={[styles.link_text, theme.typography?.label, { color: theme.colors.text }]}>{label}</Text>
58
+ </Pressable>
59
+ }
60
+
61
+ export function AuthMessage({ theme, children }: { theme: NativeTheme; children?: string | null }) {
62
+ return children ? <Text accessibilityLiveRegion="polite" accessibilityRole="alert" style={[styles.message, theme.typography?.body, { color: theme.colors.text }]}>{children}</Text> : null
63
+ }
64
+
65
+ function Field({ theme, label, children }: PropsWithChildren<{ theme: NativeTheme; label: string }>) {
66
+ return <View style={styles.field}><Text style={[styles.label, theme.typography?.label, { color: theme.colors.text }]}>{label}</Text>{children}</View>
67
+ }
68
+
69
+ function inputStyle(theme: NativeTheme) {
70
+ return [styles.input, theme.typography?.body, { backgroundColor: theme.colors.card, borderColor: theme.colors.border, color: theme.colors.text, borderRadius: theme.controlRadius ?? 12, borderWidth: theme.controlBorderWidth ?? StyleSheet.hairlineWidth }]
71
+ }
72
+
73
+ export interface SignInFormProps {
74
+ theme: NativeTheme
75
+ email: string
76
+ password: string
77
+ passwordVisible: boolean
78
+ onEmailChange: (value: string) => void
79
+ onPasswordChange: (value: string) => void
80
+ onPasswordVisibleChange: (visible: boolean) => void
81
+ onSubmit: () => void
82
+ /** Host validation and policy determine readiness; the component grants no access. */
83
+ canSubmit: boolean
84
+ busy?: boolean
85
+ message?: string | null
86
+ emailLabel?: string
87
+ passwordPlaceholder?: string
88
+ renderPasswordVisibilityIcon?: (visible: boolean) => ReactNode
89
+ passwordHelp?: ReactNode
90
+ footer?: ReactNode
91
+ }
92
+
93
+ export function SignInForm({ theme, email, password, passwordVisible, onEmailChange, onPasswordChange, onPasswordVisibleChange, onSubmit, canSubmit, busy = false, message, emailLabel = "Email", passwordPlaceholder = "Your password", renderPasswordVisibilityIcon, passwordHelp, footer }: SignInFormProps) {
94
+ const passwordInput = useRef<TextInputInstance>(null)
95
+ const submit = () => { if (canSubmit && !busy) onSubmit() }
96
+ return <View style={styles.form}>
97
+ <Field theme={theme} label={emailLabel}>
98
+ <TextInput accessibilityLabel={emailLabel} autoCapitalize="none" autoComplete="email" autoCorrect={false} blurOnSubmit={false} editable={!busy}
99
+ inputMode="email" keyboardType="email-address" maxLength={320} onChangeText={value => { if (!busy) onEmailChange(value) }}
100
+ onSubmitEditing={() => { if (!busy) passwordInput.current?.focus() }} placeholder="you@example.com" placeholderTextColor={theme.colors.muted}
101
+ returnKeyType="next" style={inputStyle(theme)} textContentType="username" value={email} />
102
+ </Field>
103
+ <Field theme={theme} label="Password">
104
+ <View style={[styles.password_field, { backgroundColor: theme.colors.card, borderColor: theme.colors.border, borderRadius: theme.controlRadius ?? 12, borderWidth: theme.controlBorderWidth ?? StyleSheet.hairlineWidth }]}>
105
+ <TextInput accessibilityLabel="Password" autoCapitalize="none" autoComplete="current-password" autoCorrect={false} editable={!busy} maxLength={1_024}
106
+ onChangeText={value => { if (!busy) onPasswordChange(value) }} onSubmitEditing={submit} placeholder={passwordPlaceholder} placeholderTextColor={theme.colors.muted}
107
+ ref={passwordInput} returnKeyType="go" secureTextEntry={!passwordVisible} style={[styles.password_input, theme.typography?.body, { color: theme.colors.text }]}
108
+ textContentType="password" value={password} />
109
+ <Pressable accessibilityLabel={passwordVisible ? "Hide password" : "Show password"} accessibilityRole="button" accessibilityState={{ disabled: busy }} disabled={busy}
110
+ onPress={() => { if (!busy) onPasswordVisibleChange(!passwordVisible) }} style={({ pressed }) => [styles.password_toggle, { opacity: pressed ? 0.6 : 1 }]}>
111
+ {renderPasswordVisibilityIcon ? renderPasswordVisibilityIcon(passwordVisible) : <Text style={[styles.link_text, { color: theme.colors.muted }]}>{passwordVisible ? "Hide" : "Show"}</Text>}
112
+ </Pressable>
113
+ </View>
114
+ {passwordHelp}
115
+ </Field>
116
+ <AuthMessage theme={theme}>{message}</AuthMessage>
117
+ <AuthButton theme={theme} disabled={!canSubmit} busy={busy} label={busy ? "Signing in…" : "Sign in"} onPress={submit} />
118
+ {footer}
119
+ </View>
120
+ }
121
+
122
+ interface SubmitFormProps {
123
+ theme: NativeTheme
124
+ busy?: boolean
125
+ canSubmit: boolean
126
+ onSubmit: () => void
127
+ message?: string | null
128
+ }
129
+
130
+ export interface AccountDetailsFormProps extends SubmitFormProps {
131
+ email: string
132
+ onEmailChange: (value: string) => void
133
+ /** Omit for existing-profile activation or password recovery. */
134
+ name?: string
135
+ onNameChange?: (value: string) => void
136
+ emailInputRef?: Ref<TextInputInstance>
137
+ }
138
+
139
+ export function AccountDetailsForm({ theme, email, onEmailChange, name, onNameChange, emailInputRef, busy = false, canSubmit, onSubmit, message }: AccountDetailsFormProps) {
140
+ const localEmailRef = useRef<TextInputInstance>(null)
141
+ const submit = () => { if (canSubmit && !busy) onSubmit() }
142
+ return <View style={styles.form}>
143
+ {name != null && onNameChange ? <Field theme={theme} label="Name">
144
+ <TextInput accessibilityLabel="Name" autoComplete="name" textContentType="name" editable={!busy} maxLength={160} onChangeText={value => { if (!busy) onNameChange(value) }}
145
+ onSubmitEditing={() => { if (!busy) localEmailRef.current?.focus() }} returnKeyType="next" style={inputStyle(theme)} value={name} />
146
+ </Field> : null}
147
+ <Field theme={theme} label="Email">
148
+ <TextInput accessibilityLabel="Email" autoCapitalize="none" autoComplete="email" autoCorrect={false} editable={!busy} inputMode="email" keyboardType="email-address" maxLength={320}
149
+ onChangeText={value => { if (!busy) onEmailChange(value) }} onSubmitEditing={submit} placeholder="you@example.com" placeholderTextColor={theme.colors.muted}
150
+ ref={value => { localEmailRef.current = value; if (typeof emailInputRef === "function") emailInputRef(value); else if (emailInputRef) emailInputRef.current = value }}
151
+ returnKeyType="go" style={inputStyle(theme)} textContentType="emailAddress" value={email} />
152
+ </Field>
153
+ <AuthMessage theme={theme}>{message}</AuthMessage>
154
+ <AuthButton theme={theme} disabled={!canSubmit} busy={busy} label={busy ? "Sending code…" : "Send code"} onPress={submit} />
155
+ </View>
156
+ }
157
+
158
+ export interface VerificationCodeFormProps extends SubmitFormProps {
159
+ code: string
160
+ onCodeChange: (value: string) => void
161
+ inputRef?: Ref<TextInputInstance>
162
+ onResend?: () => void
163
+ onEditEmail?: () => void
164
+ }
165
+
166
+ export function VerificationCodeForm({ theme, code, onCodeChange, inputRef, busy = false, canSubmit, onSubmit, onResend, onEditEmail, message }: VerificationCodeFormProps) {
167
+ const ready = canSubmit && /^(?:\d{6}|\d{8})$/u.test(code)
168
+ const submit = () => { if (ready && !busy) onSubmit() }
169
+ return <View style={styles.form}>
170
+ <Field theme={theme} label="Email code">
171
+ <TextInput key="email-code" ref={inputRef} accessibilityLabel="Email code" autoComplete="one-time-code" autoCorrect={false} editable={!busy} inputMode="numeric" keyboardType="number-pad" maxLength={8}
172
+ onChangeText={value => { if (!busy) onCodeChange(value.replace(/\D/gu, "").slice(0, 8)) }} onSubmitEditing={submit} returnKeyType="go" style={[inputStyle(theme), styles.code]} textContentType="oneTimeCode" value={code} />
173
+ </Field>
174
+ <AuthMessage theme={theme}>{message}</AuthMessage>
175
+ <AuthButton theme={theme} disabled={!ready} busy={busy} label={busy ? "Checking code…" : "Verify code"} onPress={submit} />
176
+ {onResend ? <AuthLink theme={theme} disabled={busy} label="Send a new code" onPress={onResend} /> : null}
177
+ {onEditEmail ? <AuthLink theme={theme} disabled={busy} label="Use a different email" onPress={onEditEmail} /> : null}
178
+ </View>
179
+ }
180
+
181
+ export interface NewPasswordFormProps extends SubmitFormProps {
182
+ password: string
183
+ onPasswordChange: (value: string) => void
184
+ inputRef?: Ref<TextInputInstance>
185
+ /** Native autofill guidance only. The host and API still enforce password policy. */
186
+ passwordRules?: string
187
+ onRestart?: () => void
188
+ }
189
+
190
+ export function NewPasswordForm({ theme, password, onPasswordChange, inputRef, passwordRules, busy = false, canSubmit, onSubmit, onRestart, message }: NewPasswordFormProps) {
191
+ const submit = () => { if (canSubmit && !busy) onSubmit() }
192
+ return <View style={styles.form}>
193
+ <Field theme={theme} label="New password">
194
+ <TextInput key="new-password" ref={inputRef} accessibilityLabel="New password" inputMode="text" keyboardType="default" showSoftInputOnFocus importantForAutofill="yes"
195
+ autoCapitalize="none" autoComplete={Platform.OS === "ios" ? undefined : "new-password"} autoCorrect={false} editable={!busy} maxLength={1_024}
196
+ onChangeText={value => { if (!busy) onPasswordChange(value) }} onSubmitEditing={submit} passwordRules={passwordRules} returnKeyType="go" secureTextEntry
197
+ style={inputStyle(theme)} textContentType={Platform.OS === "ios" ? "newPassword" : undefined} value={password} />
198
+ </Field>
199
+ <AuthMessage theme={theme}>{message}</AuthMessage>
200
+ <AuthButton theme={theme} disabled={!canSubmit} busy={busy} label={busy ? "Saving password…" : "Save password"} onPress={submit} />
201
+ {onRestart ? <AuthLink theme={theme} disabled={busy} label="Start again with a new code" onPress={onRestart} /> : null}
202
+ </View>
203
+ }
204
+
205
+ const styles = StyleSheet.create({
206
+ content: { width: "100%", maxWidth: 400, paddingVertical: 28, gap: 32 },
207
+ form: { gap: 20 }, field: { gap: 8 }, label: { fontSize: 13, fontWeight: "500" },
208
+ intro: { gap: 10, alignItems: "center" },
209
+ title: { fontSize: 30, lineHeight: 37, fontWeight: "600", letterSpacing: -0.8, textAlign: "center" },
210
+ description: { fontSize: 15, lineHeight: 22, alignSelf: "stretch" },
211
+ input: { minHeight: 56, paddingHorizontal: 16, fontSize: 16 },
212
+ button: { minHeight: 54, alignItems: "center", justifyContent: "center", paddingHorizontal: 18, marginTop: 4 },
213
+ button_label: { fontSize: 16, fontWeight: "600" },
214
+ link: { minHeight: 44, justifyContent: "center", alignItems: "center" }, right: { alignSelf: "flex-end" },
215
+ link_text: { fontSize: 14, fontWeight: "500", textAlign: "center" },
216
+ message: { fontSize: 14, lineHeight: 20 },
217
+ password_field: { minHeight: 56, flexDirection: "row", alignItems: "center" },
218
+ password_input: { minHeight: 54, flex: 1, paddingHorizontal: 16, fontSize: 16 },
219
+ password_toggle: { minWidth: 48, minHeight: 54, alignItems: "center", justifyContent: "center", paddingHorizontal: 8 },
220
+ code: { fontSize: 24, letterSpacing: 8 },
221
+ })
@@ -0,0 +1,51 @@
1
+ import type { PropsWithChildren, ReactNode } from "react"
2
+ import { StyleSheet, Text, View } from "react-native"
3
+ import { AuthButton, AuthMessage, type AuthButtonProps } from "./auth.tsx"
4
+ import type { NativeTheme } from "./theme.ts"
5
+
6
+ export interface BookingReviewProps extends PropsWithChildren {
7
+ theme: NativeTheme
8
+ title: string
9
+ description?: string
10
+ /** Render a success symbol only after the API has confirmed the booking. */
11
+ statusIcon?: ReactNode
12
+ details?: ReadonlyArray<{ label: string; value: string }>
13
+ message?: string | null
14
+ action?: Omit<AuthButtonProps, "theme">
15
+ footer?: ReactNode
16
+ }
17
+
18
+ export type BookingReviewHeaderProps = Pick<BookingReviewProps, "theme" | "title" | "description" | "statusIcon">
19
+
20
+ export function BookingReviewHeader({ theme, title, description, statusIcon }: BookingReviewHeaderProps) {
21
+ return <View style={styles.heading}>
22
+ {statusIcon}
23
+ <Text accessibilityRole="header" style={[styles.title, theme.typography?.heading, { color: theme.colors.text }]}>{title}</Text>
24
+ {description ? <Text style={[styles.description, theme.typography?.body, { color: theme.colors.muted }]}>{description}</Text> : null}
25
+ </View>
26
+ }
27
+
28
+ /** Review and receipt presentation. The host owns availability, policy and payment. */
29
+ export function BookingReview({ theme, title, description, statusIcon, details, message, action, footer, children }: BookingReviewProps) {
30
+ return <View style={styles.review}>
31
+ <BookingReviewHeader theme={theme} title={title} description={description} statusIcon={statusIcon} />
32
+ {details?.length ? <View style={[styles.details, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }]}>
33
+ {details.map((detail, index) => <View key={`${index}:${detail.label}`} style={styles.detail}>
34
+ <Text style={[styles.detail_label, theme.typography?.label, { color: theme.colors.muted }]}>{detail.label}</Text>
35
+ <Text style={[styles.detail_value, theme.typography?.body, { color: theme.colors.text }]}>{detail.value}</Text>
36
+ </View>)}
37
+ </View> : null}
38
+ {children}
39
+ <AuthMessage theme={theme}>{message}</AuthMessage>
40
+ {action ? <AuthButton {...action} theme={theme} /> : null}
41
+ {footer}
42
+ </View>
43
+ }
44
+
45
+ const styles = StyleSheet.create({
46
+ review: { gap: 20 }, heading: { gap: 8 },
47
+ title: { fontSize: 25, lineHeight: 31, fontWeight: "700" },
48
+ description: { fontSize: 16, lineHeight: 23 },
49
+ details: { gap: 14, padding: 18, borderWidth: StyleSheet.hairlineWidth, borderRadius: 16 },
50
+ detail: { gap: 4 }, detail_label: { fontSize: 14, lineHeight: 20 }, detail_value: { fontSize: 16, lineHeight: 23 },
51
+ })
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./auth.tsx"
2
+ export * from "./booking.tsx"
3
+ export type { NativeTheme } from "./theme.ts"
package/src/theme.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { TextStyle } from "react-native"
2
+
3
+ /** Fonts, artwork and colors are supplied by the host, never fetched by the UI. */
4
+ export interface NativeTheme {
5
+ colors: {
6
+ text: string
7
+ muted: string
8
+ card: string
9
+ border: string
10
+ primary: string
11
+ primary_contrast: string
12
+ }
13
+ typography?: { body?: TextStyle; label?: TextStyle; heading?: TextStyle }
14
+ controlRadius?: number
15
+ controlBorderWidth?: number
16
+ }