@vuoma/react-native-ui 0.0.0-stage → 0.1.1
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 +45 -0
- package/README.md +146 -2
- package/dist/auth.d.ts +89 -0
- package/dist/auth.js +77 -0
- package/dist/booking.d.ts +21 -0
- package/dist/booking.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/theme.d.ts +19 -0
- package/dist/theme.js +1 -0
- package/package.json +42 -4
- package/src/auth.tsx +221 -0
- package/src/booking.tsx +51 -0
- package/src/index.ts +3 -0
- package/src/theme.ts +16 -0
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
|
-
#
|
|
1
|
+
# @vuoma/react-native-ui
|
|
2
2
|
|
|
3
|
-
|
|
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.1 @vuoma/sdk@0.2.2
|
|
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: "username", 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;
|
package/dist/booking.js
ADDED
|
@@ -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
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/theme.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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="username" 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
|
+
})
|
package/src/booking.tsx
ADDED
|
@@ -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
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
|
+
}
|