From 7c89b144eae59012a270dea5e770e6b8e3286da6 Mon Sep 17 00:00:00 2001 From: chemicalcommando Date: Thu, 5 Mar 2026 10:43:18 +0100 Subject: [PATCH 1/3] feat(mobile): add react-hook-form zod validation setup for auth flows --- mobile/package.json | 5 +++- mobile/src/validation/auth.ts | 22 +++++++++++++++++ package-lock.json | 46 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 mobile/src/validation/auth.ts diff --git a/mobile/package.json b/mobile/package.json index 4f012af..bf43c44 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -13,15 +13,18 @@ "export:check": "expo export --platform web --output-dir dist-export --clear" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "expo": "^51.0.0", - "expo-image": "^1.12.15", "expo-haptics": "^13.0.1", + "expo-image": "^1.12.15", "expo-router": "^3.5.18", "expo-secure-store": "^13.0.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.71.2", "react-native": "0.74.5", "react-native-web": "~0.19.10", + "zod": "^4.3.6", "zustand": "^4.5.5" }, "devDependencies": { diff --git a/mobile/src/validation/auth.ts b/mobile/src/validation/auth.ts new file mode 100644 index 0000000..c5dbe4d --- /dev/null +++ b/mobile/src/validation/auth.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v3" + +export const loginSchema = z.object({ + email: z.string().email("Enter a valid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), +}) + +export type LoginFormValues = z.infer + +export const registerSchema = z + .object({ + fullName: z.string().min(2, "Full name must be at least 2 characters"), + email: z.string().email("Enter a valid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(8, "Confirm password must be at least 8 characters"), + }) + .refine((value) => value.password === value.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }) + +export type RegisterFormValues = z.infer diff --git a/package-lock.json b/package-lock.json index d296145..860092b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "name": "@discoverly/mobile", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", "expo": "^51.0.0", "expo-haptics": "^13.0.1", "expo-image": "^1.12.15", @@ -60,8 +61,10 @@ "expo-secure-store": "^13.0.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.71.2", "react-native": "0.74.5", "react-native-web": "~0.19.10", + "zod": "^4.3.6", "zustand": "^4.5.5" }, "devDependencies": { @@ -114,6 +117,15 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, + "mobile/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -4552,6 +4564,18 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -7574,6 +7598,12 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -17117,6 +17147,22 @@ "react": ">=17.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", From 704355e86959696e6e511c6195a578d1db311ca2 Mon Sep 17 00:00:00 2001 From: chemicalcommando Date: Fri, 6 Mar 2026 15:19:44 +0100 Subject: [PATCH 2/3] feat(mobile): add reusable themed button input typography and card components --- mobile/src/components/Button.tsx | 98 ++++++++++++++++++++++++++++ mobile/src/components/Card.tsx | 28 ++++++++ mobile/src/components/Input.tsx | 77 ++++++++++++++++++++++ mobile/src/components/Typography.tsx | 16 +++++ mobile/src/components/index.ts | 4 ++ mobile/src/theme/tokens.ts | 43 ++++++++++++ 6 files changed, 266 insertions(+) create mode 100644 mobile/src/components/Button.tsx create mode 100644 mobile/src/components/Card.tsx create mode 100644 mobile/src/components/Input.tsx create mode 100644 mobile/src/components/Typography.tsx create mode 100644 mobile/src/components/index.ts diff --git a/mobile/src/components/Button.tsx b/mobile/src/components/Button.tsx new file mode 100644 index 0000000..5c6f1dc --- /dev/null +++ b/mobile/src/components/Button.tsx @@ -0,0 +1,98 @@ +import type { ReactNode } from "react" +import { ActivityIndicator, Pressable, type ViewStyle } from "react-native" +import { colors, radius, spacing } from "../theme/tokens" +import { Typography } from "./Typography" + +type ButtonVariant = "primary" | "secondary" | "outlined" + +type ButtonProps = { + label: string + onPress?: () => void + variant?: ButtonVariant + loading?: boolean + disabled?: boolean + leftIcon?: ReactNode + style?: ViewStyle +} + +function getContainerStyle(variant: ButtonVariant, disabled: boolean): ViewStyle { + if (variant === "outlined") { + return { + backgroundColor: colors.surface, + borderColor: disabled ? colors.disabled : colors.crypto, + borderWidth: 1.5, + } + } + + if (variant === "secondary") { + return { + backgroundColor: disabled ? colors.disabled : colors.crypto, + } + } + + return { + backgroundColor: disabled ? colors.disabled : colors.primary, + } +} + +function getLabelColor(variant: ButtonVariant, disabled: boolean): string { + if (disabled) { + return variant === "outlined" ? colors.muted : colors.surface + } + + if (variant === "outlined") { + return colors.crypto + } + + if (variant === "secondary") { + return colors.onCrypto + } + + return colors.onPrimary +} + +export function Button({ + label, + onPress, + variant = "primary", + loading = false, + disabled = false, + leftIcon, + style, +}: ButtonProps) { + const isDisabled = disabled || loading + const labelColor = getLabelColor(variant, isDisabled) + + return ( + [ + { + minHeight: 52, + borderRadius: radius.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: spacing.sm, + opacity: pressed ? 0.9 : 1, + }, + getContainerStyle(variant, isDisabled), + style, + ]} + > + {loading ? ( + + ) : ( + <> + {leftIcon} + + {label} + + + )} + + ) +} diff --git a/mobile/src/components/Card.tsx b/mobile/src/components/Card.tsx new file mode 100644 index 0000000..c6230a7 --- /dev/null +++ b/mobile/src/components/Card.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react" +import { View, type ViewStyle } from "react-native" +import { colors, radius, shadows, spacing } from "../theme/tokens" + +type CardProps = { + children: ReactNode + style?: ViewStyle +} + +export function Card({ children, style }: CardProps) { + return ( + + {children} + + ) +} diff --git a/mobile/src/components/Input.tsx b/mobile/src/components/Input.tsx new file mode 100644 index 0000000..079875d --- /dev/null +++ b/mobile/src/components/Input.tsx @@ -0,0 +1,77 @@ +import { useState } from "react" +import { Pressable, TextInput, View, type TextInputProps, type ViewStyle } from "react-native" +import { colors, radius, spacing } from "../theme/tokens" +import { Typography } from "./Typography" + +type InputProps = TextInputProps & { + label?: string + error?: string + forceFocused?: boolean + containerStyle?: ViewStyle + rightLabel?: string + onRightPress?: () => void +} + +export function Input({ + label, + error, + forceFocused = false, + containerStyle, + rightLabel, + onRightPress, + onFocus, + onBlur, + ...props +}: InputProps) { + const [isFocused, setIsFocused] = useState(false) + const focused = forceFocused || isFocused + + return ( + + {label ? {label} : null} + + { + setIsFocused(true) + onFocus?.(event) + }} + onBlur={(event) => { + setIsFocused(false) + onBlur?.(event) + }} + style={{ + color: colors.text, + flex: 1, + paddingVertical: spacing.sm, + }} + {...props} + /> + {rightLabel ? ( + + + {rightLabel} + + + ) : null} + + {error ? ( + + {error} + + ) : null} + + ) +} diff --git a/mobile/src/components/Typography.tsx b/mobile/src/components/Typography.tsx new file mode 100644 index 0000000..55dd035 --- /dev/null +++ b/mobile/src/components/Typography.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react" +import { Text, type TextStyle } from "react-native" +import { colors, typography } from "../theme/tokens" + +type Variant = keyof typeof typography + +type TypographyProps = { + children: ReactNode + variant?: Variant + color?: string + style?: TextStyle +} + +export function Typography({ children, variant = "body", color = colors.text, style }: TypographyProps) { + return {children} +} diff --git a/mobile/src/components/index.ts b/mobile/src/components/index.ts new file mode 100644 index 0000000..eb577a9 --- /dev/null +++ b/mobile/src/components/index.ts @@ -0,0 +1,4 @@ +export * from "./Button" +export * from "./Card" +export * from "./Input" +export * from "./Typography" diff --git a/mobile/src/theme/tokens.ts b/mobile/src/theme/tokens.ts index 259cae3..4d1df4d 100644 --- a/mobile/src/theme/tokens.ts +++ b/mobile/src/theme/tokens.ts @@ -5,10 +5,15 @@ export const colors = { surface: "#FFFFFF", text: "#1D1D1F", muted: "#6B7280", + border: "#E5E7EB", error: "#D92D20", + disabled: "#C9CDD4", + onPrimary: "#FFFFFF", + onCrypto: "#FFFFFF", } export const radius = { + xs: 10, sm: 12, md: 16, lg: 24, @@ -21,3 +26,41 @@ export const spacing = { lg: 24, xl: 32, } + +export const typography = { + h1: { + fontSize: 34, + lineHeight: 40, + fontWeight: "700" as const, + }, + h2: { + fontSize: 28, + lineHeight: 34, + fontWeight: "700" as const, + }, + h3: { + fontSize: 22, + lineHeight: 28, + fontWeight: "600" as const, + }, + body: { + fontSize: 16, + lineHeight: 22, + fontWeight: "400" as const, + }, + caption: { + fontSize: 13, + lineHeight: 18, + fontWeight: "500" as const, + }, +} + +export const shadows = { + soft: { + shadowColor: "#101828", + shadowOpacity: 0.12, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 4, + }, +} From a9f1788208fdab7190450bed7d6e2d4ee35d97eb Mon Sep 17 00:00:00 2001 From: chemicalcommando Date: Sat, 7 Mar 2026 13:27:09 +0100 Subject: [PATCH 3/3] feat(mobile): implement validated login and registration form flows --- mobile/app/(auth)/login.tsx | 82 +++++++++++++++++++--- mobile/app/(auth)/register.tsx | 123 +++++++++++++++++++++++++++++---- 2 files changed, 184 insertions(+), 21 deletions(-) diff --git a/mobile/app/(auth)/login.tsx b/mobile/app/(auth)/login.tsx index a47c0dd..1f6159a 100644 --- a/mobile/app/(auth)/login.tsx +++ b/mobile/app/(auth)/login.tsx @@ -1,21 +1,85 @@ -import { Link } from "expo-router" -import { Text, View } from "react-native" +import { zodResolver } from "@hookform/resolvers/zod" +import { Link, useRouter } from "expo-router" +import { Controller, useForm } from "react-hook-form" +import { View } from "react-native" +import { Button, Input, Typography } from "../../src/components" +import { useAuthStore } from "../../src/store/useAuthStore" +import { colors, spacing } from "../../src/theme/tokens" +import { type LoginFormValues, loginSchema } from "../../src/validation/auth" export default function LoginScreen() { + const router = useRouter() + const setToken = useAuthStore((state) => state.setToken) + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: "", + password: "", + }, + mode: "onBlur", + }) + + const onSubmit = async ({ email }: LoginFormValues) => { + const token = `session_${email.toLowerCase()}` + setToken(token) + router.replace("/(tabs)/discover") + } + return ( - Welcome Back - Phase 1 auth UI scaffold. - Mock Sign In + Welcome Back + + Sign in to continue matching with dishes around you. + + ( + + )} + /> + ( + + )} + /> +