diff --git a/apps/browser/package.json b/apps/browser/package.json index 63b2b89..fffc8d1 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -13,8 +13,11 @@ }, "dependencies": { "@entityseven/rage-fw-browser": "0.2.0", + "@tailwindcss/vite": "^4.0.17", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "tailwindcss": "^4.0.17", + "zod": "^3.24.2" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/apps/browser/src/App.tsx b/apps/browser/src/App.tsx index 0d9e5de..1d69fc1 100644 --- a/apps/browser/src/App.tsx +++ b/apps/browser/src/App.tsx @@ -1,26 +1,30 @@ -import { fw } from '@entityseven/rage-fw-browser' -import { useEffect, useState } from 'react' +import React from 'react' +import LoginPage from './pages/Authorization/LoginPage' +import RegisterPage from './pages/Authorization/RegisterPage' + +import './index.css' function App() { - const [data, setData] = useState('initial') - - useEffect(() => { - fw.event.register('customBrowserEvent', async message => { - setData(p => p + ' | ' + message) - - const response = await fw.event.triggerServer('customServerEvent', [ - 'hello from browser', - ]) - setData(p => p + ' | ' + response) - - return 'response from browser' - }) - }, []) + const [showLogin, setShowLogin] = React.useState(true) return ( -
-

Hello World!

-

{data}

+
+
+ + +
+ + {showLogin ? : }
) } diff --git a/apps/browser/src/components/ui/Button.tsx b/apps/browser/src/components/ui/Button.tsx new file mode 100644 index 0000000..b71705a --- /dev/null +++ b/apps/browser/src/components/ui/Button.tsx @@ -0,0 +1,62 @@ +import React from 'react' + +interface ButtonProps extends React.ButtonHTMLAttributes { + isLoading?: boolean +} + +const Button: React.FC = ({ + children, + className, + isLoading = false, + disabled, + ...props +}) => { + const baseClasses = + 'w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2' + const activeClasses = 'bg-sky-600 hover:bg-sky-700 focus:ring-sky-500' + const loadingClasses = 'bg-sky-400 cursor-not-allowed' + const disabledClasses = 'bg-gray-400 cursor-not-allowed' + + const getButtonClasses = () => { + if (isLoading) + return `${baseClasses} ${loadingClasses} ${className || ''}` + if (disabled) + return `${baseClasses} ${disabledClasses} ${className || ''}` + return `${baseClasses} ${activeClasses} ${className || ''}` + } + + return ( + + ) +} + +export default Button diff --git a/apps/browser/src/components/ui/Checkbox.tsx b/apps/browser/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..bf46495 --- /dev/null +++ b/apps/browser/src/components/ui/Checkbox.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +interface CheckboxProps extends React.InputHTMLAttributes { + label: string + id: string +} + +const Checkbox: React.FC = ({ + label, + id, + className, + ...props +}) => { + return ( +
+ + +
+ ) +} + +export default Checkbox diff --git a/apps/browser/src/components/ui/Input.tsx b/apps/browser/src/components/ui/Input.tsx new file mode 100644 index 0000000..b696c19 --- /dev/null +++ b/apps/browser/src/components/ui/Input.tsx @@ -0,0 +1,49 @@ +import React from 'react' + +interface InputProps extends React.InputHTMLAttributes { + label: string + id: string + error?: string +} + +const Input: React.FC = ({ + label, + id, + error, + className, + ...props +}) => { + const baseClasses = + 'mt-1 block w-full px-3 py-2 bg-white border border-slate-300 rounded-md text-sm shadow-sm placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500 disabled:bg-slate-50 disabled:text-slate-500 disabled:border-slate-200 disabled:shadow-none' + const errorClasses = + 'border-red-500 text-red-600 focus:border-red-500 focus:ring-red-500' + + return ( +
+ + + {error && ( + + )} +
+ ) +} + +export default Input diff --git a/apps/browser/src/hooks/useLoginForm.ts b/apps/browser/src/hooks/useLoginForm.ts new file mode 100644 index 0000000..2dc6078 --- /dev/null +++ b/apps/browser/src/hooks/useLoginForm.ts @@ -0,0 +1,139 @@ +import React, { useState, useCallback } from 'react' +import { loginSchema, LoginFormData, FieldErrors } from '../validation' +import { ZodError } from 'zod' + +interface UseLoginFormProps { + onSubmitSuccess?: (data: LoginFormData) => void // Optional callback for successful submission +} + +export function useLoginForm({ onSubmitSuccess }: UseLoginFormProps = {}) { + const [formData, setFormData] = useState({ + identifier: '', // Can hold email or username + password: '', + saveLogin: false, + savePassword: false, + }) + const [errors, setErrors] = useState>({}) + const [isLoading, setIsLoading] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })) + // Clear error for the field being edited + if (errors[name as keyof LoginFormData]) { + setErrors(prev => ({ ...prev, [name]: undefined })) + } + // Clear general submission error + if (submitError) { + setSubmitError(null) + } + }, + [errors, submitError], + ) + + const validateForm = useCallback(() => { + try { + loginSchema.parse(formData) + setErrors({}) + return true + } catch (error) { + if (error instanceof ZodError) { + const fieldErrors: FieldErrors = {} + error.errors.forEach(err => { + if (err.path.length > 0) { + fieldErrors[err.path[0] as keyof LoginFormData] = + err.message + } + }) + setErrors(fieldErrors) + } else { + console.error( + 'An unexpected error occurred during validation:', + error, + ) + setSubmitError('An unexpected validation error occurred.') + } + return false + } + }, [formData]) + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + setSubmitError(null) + + if (!validateForm()) { + return + } + + setIsLoading(true) + console.log('Submitting login data:', formData) + + // --- TODO: Replace with actual API call to your RageMP server --- + try { + // Example: Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)) + + // Assuming API returns success: + console.log('Login successful!') + // Here you would typically receive a token or session info + // Handle 'saveLogin' and 'savePassword' (e.g., using localStorage/sessionStorage) + if (formData.saveLogin) { + localStorage.setItem('savedUsername', formData.identifier) // Example + } else { + localStorage.removeItem('savedUsername') // Example + } + // Password saving is generally discouraged for security reasons, + // but if required: + if (formData.savePassword) { + // Be VERY careful with storing passwords. Consider secure storage or tokens. + // localStorage.setItem('savedPassword', formData.password); // **Highly discouraged** + console.warn( + 'Password saving enabled - ensure secure storage mechanism.', + ) + } + + setErrors({}) // Clear errors on success + if (onSubmitSuccess) { + onSubmitSuccess(formData) // Call success callback + } + // You might redirect the user or update application state here + + // --- Mock Error Handling (remove in real implementation) --- + // if (formData.identifier === 'wrong') { + // throw new Error("Invalid credentials."); + // } + // --- End Mock Error Handling --- + } catch (apiError: unknown) { + console.error('Login API error:', apiError) + setSubmitError('Login failed. Please check your credentials.') + } finally { + setIsLoading(false) + } + // --- End API call section --- + }, + [formData, validateForm, onSubmitSuccess], + ) + + // Effect to potentially load saved username on initial render (example) + // useEffect(() => { + // const savedUser = localStorage.getItem('savedUsername'); + // if (savedUser) { + // setFormData(prev => ({ ...prev, identifier: savedUser, saveLogin: true })); + // } + // }, []); + + return { + formData, + errors, + isLoading, + submitError, + handleChange, + handleSubmit, + } +} diff --git a/apps/browser/src/hooks/useRegisterForm.ts b/apps/browser/src/hooks/useRegisterForm.ts new file mode 100644 index 0000000..77b83ad --- /dev/null +++ b/apps/browser/src/hooks/useRegisterForm.ts @@ -0,0 +1,121 @@ +import React, { useState, useCallback } from 'react' +import { registerSchema, RegisterFormData, FieldErrors } from '../validation' +import { ZodError } from 'zod' + +interface UseRegisterFormProps { + onSubmitSuccess?: (data: RegisterFormData) => void // Optional callback for successful submission +} + +export function useRegisterForm({ + onSubmitSuccess, +}: UseRegisterFormProps = {}) { + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + passwordRepeat: '', + promocode: '', + }) + const [errors, setErrors] = useState>({}) + const [isLoading, setIsLoading] = useState(false) + const [submitError, setSubmitError] = useState(null) // For general API errors + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData(prev => ({ ...prev, [name]: value })) + // Clear error for the field being edited + if (errors[name as keyof RegisterFormData]) { + setErrors(prev => ({ ...prev, [name]: undefined })) + } + // Clear general submission error when user starts typing again + if (submitError) { + setSubmitError(null) + } + }, + [errors, submitError], + ) + + const validateForm = useCallback(() => { + try { + registerSchema.parse(formData) + setErrors({}) // Clear errors if validation passes + return true + } catch (error) { + if (error instanceof ZodError) { + const fieldErrors: FieldErrors = {} + error.errors.forEach(err => { + if (err.path.length > 0) { + fieldErrors[err.path[0] as keyof RegisterFormData] = + err.message + } + }) + setErrors(fieldErrors) + } else { + console.error( + 'An unexpected error occurred during validation:', + error, + ) + setSubmitError('An unexpected validation error occurred.') // Generic error for non-Zod issues + } + return false + } + }, [formData]) + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + setSubmitError(null) // Clear previous submit errors + + if (!validateForm()) { + return // Stop submission if validation fails + } + + setIsLoading(true) + console.log('Submitting registration data:', formData) + + // --- TODO: Replace with actual API call to your RageMP server --- + try { + // Example: Simulate API call + await new Promise(resolve => setTimeout(resolve, 1500)) + + // Assuming API returns success: + console.log('Registration successful!') + setFormData({ + username: '', + email: '', + password: '', + passwordRepeat: '', + promocode: '', + }) // Reset form + setErrors({}) + if (onSubmitSuccess) { + onSubmitSuccess(formData) // Call success callback if provided + } + + // --- Mock Error Handling (remove in real implementation) --- + // if (formData.email.includes('fail')) { + // throw new Error("Registration failed: Email already exists."); + // } + // --- End Mock Error Handling --- + } catch (apiError: unknown) { + console.error('Registration API error:', apiError) + // Try to set a user-friendly error message + setSubmitError('Registration failed. Please try again.') + } finally { + setIsLoading(false) + } + // --- End API call section --- + }, + [formData, validateForm, onSubmitSuccess], + ) + + return { + formData, + errors, + isLoading, + submitError, + handleChange, + handleSubmit, + } +} diff --git a/apps/browser/src/index.css b/apps/browser/src/index.css index 293d3b1..03d224a 100644 --- a/apps/browser/src/index.css +++ b/apps/browser/src/index.css @@ -1,3 +1,5 @@ +@import "tailwindcss"; + body { margin: 0; } diff --git a/apps/browser/src/pages/Authorization/LoginPage.tsx b/apps/browser/src/pages/Authorization/LoginPage.tsx new file mode 100644 index 0000000..b7961ea --- /dev/null +++ b/apps/browser/src/pages/Authorization/LoginPage.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import Input from '../../components/ui/Input' +import Checkbox from '../../components/ui/Checkbox' +import Button from '../../components/ui/Button' +import { useLoginForm } from '../../hooks/useLoginForm' +import { LoginFormData } from '../../validation' + +const LoginPage: React.FC = () => { + const handleLoginSuccess = (data: LoginFormData) => { + console.log('Login success callback triggered:', data) + // Redirect user or update app state + alert(`Login successful for ${data.identifier}!`) + } + + const { + formData, + errors, + isLoading, + submitError, + handleChange, + handleSubmit, + } = useLoginForm({ onSubmitSuccess: handleLoginSuccess }) + + return ( +
+
+
+

+ Sign in to your account +

+ {/* Optional: Link to registration page */} + {/*

+ Or{' '} + + create a new account + +

*/} +
+
+ {/* Display general submission errors */} + {submitError && ( +
+ Error: + + {submitError} + +
+ )} + +
+ + +
+ +
+
+ + +
+ + {/* Optional: Forgot Password Link */} + {/* */} +
+ +
+ +
+
+
+
+ ) +} + +export default LoginPage diff --git a/apps/browser/src/pages/Authorization/RegisterPage.tsx b/apps/browser/src/pages/Authorization/RegisterPage.tsx new file mode 100644 index 0000000..63c3d36 --- /dev/null +++ b/apps/browser/src/pages/Authorization/RegisterPage.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import Input from '../../components/ui/Input' +import Button from '../../components/ui/Button' +import { useRegisterForm } from '../../hooks/useRegisterForm' +import { RegisterFormData } from '../../validation' + +const RegisterPage: React.FC = () => { + const handleRegistrationSuccess = (data: RegisterFormData) => { + console.log('Registration success callback triggered:', data) + // Maybe show a success message or redirect + alert(`Registration successful for ${data.username}!`) + } + + const { + formData, + errors, + isLoading, + submitError, + handleChange, + handleSubmit, + } = useRegisterForm({ onSubmitSuccess: handleRegistrationSuccess }) + + return ( +
+
+
+

+ Create your account +

+ {/* Optional: Link to login page */} + {/*

+ Or{' '} + + sign in to your existing account + +

*/} +
+
+ {/* Display general submission errors */} + {submitError && ( +
+ Error: + + {submitError} + +
+ )} + + {/* Use hidden input for honeypot or CSRF token if needed */} + {/* */} +
+ + + + + +
+ +
+ +
+
+
+
+ ) +} + +export default RegisterPage diff --git a/apps/browser/src/validation/index.ts b/apps/browser/src/validation/index.ts new file mode 100644 index 0000000..0554432 --- /dev/null +++ b/apps/browser/src/validation/index.ts @@ -0,0 +1,6 @@ +export * from './loginSchema.ts' +export * from './registerSchema.ts' + +export type FieldErrors = { + [K in keyof T]?: string | undefined +} diff --git a/apps/browser/src/validation/loginSchema.ts b/apps/browser/src/validation/loginSchema.ts new file mode 100644 index 0000000..23e5281 --- /dev/null +++ b/apps/browser/src/validation/loginSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const loginSchema = z.object({ + identifier: z.string().min(1, { message: 'Email or Username is required' }), // Can be email or username + password: z.string().min(1, { message: 'Password is required' }), + saveLogin: z.boolean().optional().default(false), + savePassword: z.boolean().optional().default(false), +}) + +export type LoginFormData = z.infer diff --git a/apps/browser/src/validation/registerSchema.ts b/apps/browser/src/validation/registerSchema.ts new file mode 100644 index 0000000..0f9121c --- /dev/null +++ b/apps/browser/src/validation/registerSchema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' + +// --- Registration Schema --- +export const registerSchema = z + .object({ + username: z + .string() + .min(3, { message: 'Username must be at least 3 characters long' }) + .max(20, { message: 'Username cannot exceed 20 characters' }) + .regex(/^[a-zA-Z0-9_]+$/, { + message: + 'Username can only contain letters, numbers, and underscores', + }), + email: z.string().email({ message: 'Invalid email address' }), + password: z + .string() + .min(8, { message: 'Password must be at least 8 characters long' }), + // Optional: Add more complexity requirements if needed + // .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/, { message: "Password must contain uppercase, lowercase, and number" }) + passwordRepeat: z.string(), + promocode: z.string().optional(), // Promocode is optional + }) + .refine(data => data.password === data.passwordRepeat, { + message: "Passwords don't match", + path: ['passwordRepeat'], // Set error path to passwordRepeat field + }) + +export type RegisterFormData = z.infer diff --git a/apps/browser/vite.config.ts b/apps/browser/vite.config.ts index e84ee6e..47fe383 100644 --- a/apps/browser/vite.config.ts +++ b/apps/browser/vite.config.ts @@ -1,11 +1,12 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], - build: { - outDir: '../../server/client_packages/cef', - emptyOutDir: true - } + plugins: [react(), tailwindcss()], + build: { + outDir: '../../server/client_packages/cef', + emptyOutDir: true, + }, })