Skip to content

Commit ac64146

Browse files
committed
feat(mobile): auth validation parity, stretchy header, boxed error alerts
Bring the mobile auth screens up to the web app's behavior and add native polish. Validation parity (matches web/src/pages/Login.tsx + Register.tsx): - Shared auth store now surfaces errors via apiErrorMessage() (network/CORS, 5xx, misconfigured-URL hints) instead of a bare err.response.data.error — same message logic the web pages use. - Login: Sign-in gated on a valid email shape + non-empty password (RN equivalent of `type=email required`), with an inline invalid-email hint. - Register: adds the missing Confirm-password field and the same two checks in the same order/copy — "Passwords do not match", then the 8-char rule. Stretchy pull-down hero (AuthScaffold): - ScrollView → Animated.ScrollView with bounces re-enabled; on overscroll the gradient background stretches from its top edge (transformOrigin, RN 0.81+) and is pinned to the viewport so the notch stays covered and the sheet overlap is preserved at every pull depth. Watermark gets a parallax drift. Boxed error alerts (authui AuthError): - Reusable alert mirroring web's .alert-error (tinted bg, error border, AlertCircle icon), with a 200ms fade/slide entrance; used in both screens. Light stays the default; all of the above verified in light + dark via the web driver. type-check clean, shared tests green.
1 parent f00b02a commit ac64146

5 files changed

Lines changed: 160 additions & 40 deletions

File tree

mobile/app/(auth)/login.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,33 @@ import { useState } from 'react'
22
import { Text, View, Linking } from 'react-native'
33
import { Link } from 'expo-router'
44
import { AuthScaffold } from '../../src/components/AuthScaffold'
5-
import { IconInput, GradientButton, SecondaryButton, AuthDivider, ServerRow, Footer } from '../../src/components/authui'
5+
import { IconInput, GradientButton, SecondaryButton, AuthDivider, AuthError, ServerRow, Footer } from '../../src/components/authui'
66
import { useAuthStore } from '../../src/lib/lyftr'
77
import { useTheme } from '../../src/theme/useTheme'
88

99
// Public hosted demo (Fly) — the "Try demo account" button opens it in the browser.
1010
const DEMO_URL = 'https://lyftr-demo.fly.dev'
1111

12+
// Same intent as the web's <input type=email required>: a lightweight shape check,
13+
// not RFC validation — the server has the final say.
14+
const EMAIL_RE = /^\S+@\S+\.\S+$/
15+
1216
export default function Login() {
1317
const [email, setEmail] = useState('')
1418
const [password, setPassword] = useState('')
1519
const login = useAuthStore((s) => s.login)
1620
const loading = useAuthStore((s) => s.isLoading)
1721
const error = useAuthStore((s) => s.error)
1822
const clearError = useAuthStore((s) => s.clearError)
19-
const { accent, colors } = useTheme()
23+
const { accent, colors, brand } = useTheme()
24+
25+
// RN has no HTML `required`, so gate the button instead: both fields filled and the
26+
// email looks like an email (mirrors the web form's native validation).
27+
const emailOk = EMAIL_RE.test(email.trim())
28+
const canSubmit = emailOk && password.length > 0
2029

2130
const submit = async () => {
31+
if (!canSubmit) return
2232
try { await login(email.trim(), password) } catch {}
2333
}
2434
const demo = () => {
@@ -36,6 +46,11 @@ export default function Login() {
3646
keyboardType="email-address"
3747
placeholder="you@example.com"
3848
/>
49+
{email.length > 0 && !emailOk ? (
50+
<Text style={{ marginTop: 7, fontFamily: 'PlusJakartaSans_600SemiBold', fontSize: 12, color: brand.error }}>
51+
Enter a valid email address
52+
</Text>
53+
) : null}
3954
<IconInput
4055
label="Password"
4156
icon="lock"
@@ -44,12 +59,8 @@ export default function Login() {
4459
onChangeText={(t) => { clearError(); setPassword(t) }}
4560
placeholder="••••••••"
4661
/>
47-
{error ? (
48-
<Text style={{ marginTop: 12, color: '#f87171', fontFamily: 'PlusJakartaSans_600SemiBold', fontSize: 13 }}>
49-
{error}
50-
</Text>
51-
) : null}
52-
<GradientButton title="Sign in" onPress={submit} loading={loading} />
62+
{error ? <AuthError message={error} /> : null}
63+
<GradientButton title="Sign in" onPress={submit} loading={loading} disabled={!canSubmit} />
5364
<AuthDivider />
5465
<SecondaryButton title="Try demo account" hint="no sign-up" onPress={demo} />
5566
<Footer>

mobile/app/(auth)/register.tsx

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,53 @@ import { useState } from 'react'
22
import { Text, View } from 'react-native'
33
import { Link } from 'expo-router'
44
import { AuthScaffold } from '../../src/components/AuthScaffold'
5-
import { IconInput, GradientButton, ServerRow, Footer } from '../../src/components/authui'
5+
import { IconInput, GradientButton, AuthError, ServerRow, Footer } from '../../src/components/authui'
66
import { useAuthStore } from '../../src/lib/lyftr'
77
import { useTheme } from '../../src/theme/useTheme'
88

9+
// Same intent as the web's <input type=email required>: a lightweight shape check,
10+
// not RFC validation — the server has the final say.
11+
const EMAIL_RE = /^\S+@\S+\.\S+$/
12+
913
export default function Register() {
1014
const [email, setEmail] = useState('')
1115
const [password, setPassword] = useState('')
16+
const [passwordConfirm, setPasswordConfirm] = useState('')
17+
// Client-side validation errors (match / length), shown on submit like the web form.
18+
const [localError, setLocalError] = useState<string | null>(null)
1219
const register = useAuthStore((s) => s.register)
1320
const loading = useAuthStore((s) => s.isLoading)
1421
const error = useAuthStore((s) => s.error)
1522
const clearError = useAuthStore((s) => s.clearError)
1623
const { accent, colors } = useTheme()
1724

18-
const localError =
19-
password.length > 0 && password.length < 8 ? 'Password must be at least 8 characters' : null
25+
// Web equivalent of `required` on all three fields (email also type=email).
26+
const canSubmit = EMAIL_RE.test(email.trim()) && password.length > 0 && passwordConfirm.length > 0
27+
28+
const onChange = (setter: (t: string) => void) => (t: string) => {
29+
clearError()
30+
setLocalError(null)
31+
setter(t)
32+
}
2033

2134
const submit = async () => {
22-
if (localError) return
35+
if (!canSubmit) return
36+
// Same checks, same order, same copy as web/src/pages/Register.tsx.
37+
if (password !== passwordConfirm) { setLocalError('Passwords do not match'); return }
38+
if (password.length < 8) { setLocalError('Password must be at least 8 characters'); return }
2339
try { await register(email.trim(), password) } catch {}
2440
}
2541

42+
const shownError = localError || error
43+
2644
return (
2745
<AuthScaffold heading="Create account" subtitle="Start your training log.">
2846
<ServerRow />
2947
<IconInput
3048
label="Email"
3149
icon="mail"
3250
value={email}
33-
onChangeText={(t) => { clearError(); setEmail(t) }}
51+
onChangeText={onChange(setEmail)}
3452
keyboardType="email-address"
3553
placeholder="you@example.com"
3654
/>
@@ -39,15 +57,19 @@ export default function Register() {
3957
icon="lock"
4058
password
4159
value={password}
42-
onChangeText={(t) => { clearError(); setPassword(t) }}
60+
onChangeText={onChange(setPassword)}
4361
placeholder="At least 8 characters"
4462
/>
45-
{(localError || error) ? (
46-
<Text style={{ marginTop: 12, color: '#f87171', fontFamily: 'PlusJakartaSans_600SemiBold', fontSize: 13 }}>
47-
{localError || error}
48-
</Text>
49-
) : null}
50-
<GradientButton title="Create account" onPress={submit} loading={loading} disabled={!!localError} />
63+
<IconInput
64+
label="Confirm password"
65+
icon="lock"
66+
password
67+
value={passwordConfirm}
68+
onChangeText={onChange(setPasswordConfirm)}
69+
placeholder="••••••••"
70+
/>
71+
{shownError ? <AuthError message={shownError} /> : null}
72+
<GradientButton title="Create account" onPress={submit} loading={loading} disabled={!canSubmit} />
5173
<Footer>
5274
<View style={{ flexDirection: 'row', gap: 5 }}>
5375
<Text style={{ color: colors.txSecondary, fontFamily: 'PlusJakartaSans_600SemiBold', fontSize: 14 }}>Have an account?</Text>

mobile/src/components/AuthScaffold.tsx

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ReactNode } from 'react'
2-
import { View, Text, KeyboardAvoidingView, Platform, ScrollView } from 'react-native'
1+
import { ReactNode, useRef, useState } from 'react'
2+
import { View, Text, KeyboardAvoidingView, Platform, Animated } from 'react-native'
33
import { LinearGradient } from 'expo-linear-gradient'
44
import { StatusBar } from 'expo-status-bar'
55
import { SafeAreaView } from 'react-native-safe-area-context'
@@ -19,32 +19,76 @@ export function AuthScaffold({
1919
children: ReactNode
2020
}) {
2121
const { colors } = useTheme()
22+
23+
// Stretchy pull-down header (classic iOS): track the scroll offset and, on
24+
// overscroll (negative scrollY, iOS bounce), stretch the hero's gradient BACKGROUND
25+
// while the hero text rides down with the content. The background is (1) pinned to
26+
// the viewport top by translating it against the scroll and (2) scaled from its top
27+
// edge (transformOrigin 'top', RN 0.81+) by 1 + pull/heroH, so its bottom edge
28+
// tracks the sheet exactly — no base-color gap ever peeks above the gradient or
29+
// between gradient and sheet, and the notch/safe area stays covered. Both
30+
// interpolations clamp at 0 so normal (positive) scrolling is untouched.
31+
const scrollY = useRef(new Animated.Value(0)).current
32+
const [heroH, setHeroH] = useState(320) // measured; fallback ≈ real hero height
33+
const bgTranslate = scrollY.interpolate({
34+
inputRange: [-1, 0],
35+
outputRange: [-1, 0], // identity for pulls (extrapolates left), 0 once scrolled
36+
extrapolateRight: 'clamp',
37+
})
38+
const bgScale = scrollY.interpolate({
39+
inputRange: [-heroH, 0],
40+
outputRange: [2, 1],
41+
extrapolateRight: 'clamp',
42+
})
43+
2244
return (
2345
<KeyboardAvoidingView
2446
style={{ flex: 1, backgroundColor: colors.base }}
2547
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
2648
>
2749
{/* Hero is always the brand gradient (dark), so the status bar stays light. */}
2850
<StatusBar style="light" />
29-
<ScrollView
51+
<Animated.ScrollView
3052
contentContainerStyle={{ flexGrow: 1 }}
3153
keyboardShouldPersistTaps="handled"
3254
showsVerticalScrollIndicator={false}
33-
bounces={false}
55+
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
56+
useNativeDriver: Platform.OS !== 'web', // RN-web has no native driver
57+
})}
58+
scrollEventThrottle={16}
3459
>
35-
{/* HERO */}
36-
<LinearGradient
37-
colors={['#00b8d9', '#8b5cf6']}
38-
start={{ x: 0, y: 0 }}
39-
end={{ x: 1, y: 1 }}
60+
{/* HERO — gradient bg is absolute so it can stretch independently of the text */}
61+
<View
62+
onLayout={(e) => setHeroH(Math.max(1, Math.round(e.nativeEvent.layout.height)))}
4063
style={{ paddingBottom: 46 }}
4164
>
42-
<View
65+
<Animated.View
4366
pointerEvents="none"
44-
style={{ position: 'absolute', right: -60, top: 118, opacity: 0.15, transform: [{ rotate: '-8deg' }] }}
67+
style={{
68+
position: 'absolute',
69+
top: 0,
70+
left: 0,
71+
right: 0,
72+
bottom: 0,
73+
transformOrigin: 'top',
74+
transform: [{ translateY: bgTranslate }, { scale: bgScale }],
75+
}}
4576
>
46-
<BarbellMark size={260} bar="#ffffff" plate="#ffffff" plateEdge="#ffffff" highlight={false} />
47-
</View>
77+
<LinearGradient
78+
colors={['#00b8d9', '#8b5cf6']}
79+
start={{ x: 0, y: 0 }}
80+
end={{ x: 1, y: 1 }}
81+
style={{ flex: 1 }}
82+
>
83+
{/* Watermark lives in the stretchy layer → subtle parallax drift on pull. */}
84+
<View
85+
pointerEvents="none"
86+
style={{ position: 'absolute', right: -60, top: 118, opacity: 0.15, transform: [{ rotate: '-8deg' }] }}
87+
>
88+
<BarbellMark size={260} bar="#ffffff" plate="#ffffff" plateEdge="#ffffff" highlight={false} />
89+
</View>
90+
</LinearGradient>
91+
</Animated.View>
4892

4993
<SafeAreaView edges={['top']}>
5094
<View style={{ paddingHorizontal: 28, paddingTop: 14 }}>
@@ -106,7 +150,7 @@ export function AuthScaffold({
106150
</Text>
107151
</View>
108152
</SafeAreaView>
109-
</LinearGradient>
153+
</View>
110154

111155
{/* SHEET */}
112156
<View
@@ -127,7 +171,7 @@ export function AuthScaffold({
127171
/>
128172
{children}
129173
</View>
130-
</ScrollView>
174+
</Animated.ScrollView>
131175
</KeyboardAvoidingView>
132176
)
133177
}

mobile/src/components/authui.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { ReactNode, useState } from 'react'
1+
import { ReactNode, useEffect, useRef, useState } from 'react'
22
import {
33
View,
44
Text,
55
TextInput,
66
TextInputProps,
77
Pressable,
88
ActivityIndicator,
9+
Animated,
10+
Platform,
911
} from 'react-native'
1012
import { LinearGradient } from 'expo-linear-gradient'
11-
import { Mail, Lock, Eye, EyeOff, Server, ChevronDown, LogIn, Play } from 'lucide-react-native'
13+
import { AlertCircle, Mail, Lock, Eye, EyeOff, Server, ChevronDown, LogIn, Play } from 'lucide-react-native'
1214
import { useServerStore } from '../lib/lyftr'
1315
import { useTheme } from '../theme/useTheme'
1416
import { testServerConnection, normalizeServerUrl } from '@lyftr/shared'
@@ -74,6 +76,45 @@ export function IconInput({
7476
)
7577
}
7678

79+
// Boxed error alert — mirror of the web's `.alert-error` (soft error-tinted bg, error
80+
// border, AlertCircle icon). Text is errorSoft on dark but the stronger error red on
81+
// light, where errorSoft doesn't have enough contrast. Fades/slides in on appearance.
82+
export function AuthError({ message }: { message: string }) {
83+
const { isDark, brand } = useTheme()
84+
const anim = useRef(new Animated.Value(0)).current
85+
useEffect(() => {
86+
anim.setValue(0)
87+
Animated.timing(anim, {
88+
toValue: 1,
89+
duration: 200,
90+
useNativeDriver: Platform.OS !== 'web',
91+
}).start()
92+
}, [message, anim])
93+
const tint = isDark ? brand.errorSoft : brand.error
94+
return (
95+
<Animated.View
96+
style={{
97+
marginTop: 16,
98+
flexDirection: 'row',
99+
alignItems: 'flex-start',
100+
gap: 10,
101+
padding: 14,
102+
borderRadius: 12,
103+
backgroundColor: 'rgba(239,68,68,0.1)',
104+
borderWidth: 1,
105+
borderColor: 'rgba(239,68,68,0.2)',
106+
opacity: anim,
107+
transform: [{ translateY: anim.interpolate({ inputRange: [0, 1], outputRange: [-4, 0] }) }],
108+
}}
109+
>
110+
<AlertCircle size={16} color={tint} strokeWidth={2.2} style={{ marginTop: 1.5 }} />
111+
<Text style={{ flex: 1, fontFamily: FONT.body, fontSize: 13, lineHeight: 19, color: tint }}>
112+
{message}
113+
</Text>
114+
</Animated.View>
115+
)
116+
}
117+
77118
export function GradientButton({
78119
title,
79120
onPress,

packages/shared/src/stores/auth.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { create } from 'zustand'
22
import * as types from '../types'
33
import { StorageAdapter, STORAGE_KEYS } from '../storage'
4-
import { LyftrClient } from '../client'
4+
import { LyftrClient, apiErrorMessage } from '../client'
55

66
export interface AuthStore {
77
user: types.User | null
@@ -45,7 +45,9 @@ export function createAuthStore(client: LyftrClient, storage: StorageAdapter) {
4545
await storage.set(STORAGE_KEYS.user, JSON.stringify(data.user))
4646
set({ user: data.user, isAuthenticated: true, isLoading: false })
4747
} catch (err: any) {
48-
set({ error: err.response?.data?.error || 'Login failed', isLoading: false })
48+
// Same message logic as the web login page: server-provided error if present,
49+
// else a status-aware hint (network/CORS, 5xx, misconfigured URL) via apiErrorMessage.
50+
set({ error: apiErrorMessage(err, 'Invalid email or password.'), isLoading: false })
4951
throw err
5052
}
5153
},
@@ -59,7 +61,7 @@ export function createAuthStore(client: LyftrClient, storage: StorageAdapter) {
5961
await storage.set(STORAGE_KEYS.user, JSON.stringify(data.user))
6062
set({ user: data.user, isAuthenticated: true, isLoading: false })
6163
} catch (err: any) {
62-
set({ error: err.response?.data?.error || 'Registration failed', isLoading: false })
64+
set({ error: apiErrorMessage(err, 'Registration failed.'), isLoading: false })
6365
throw err
6466
}
6567
},

0 commit comments

Comments
 (0)