diff --git a/package.json b/package.json
index d53576ab..46571a4c 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
+ "@testing-library/user-event": "^14.5.2",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f38b54dd..ef9f79da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,6 +33,9 @@ importers:
'@testing-library/react':
specifier: ^16.0.1
version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/user-event':
+ specifier: ^14.5.2
+ version: 14.5.2(@testing-library/dom@10.4.0)
'@types/node':
specifier: ^22.9.0
version: 22.9.0
@@ -612,6 +615,12 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.5.2':
+ resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -2374,6 +2383,10 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)':
+ dependencies:
+ '@testing-library/dom': 10.4.0
+
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
diff --git a/src/components/Dummy.test.tsx b/src/components/Dummy.test.tsx
deleted file mode 100644
index 330a24a3..00000000
--- a/src/components/Dummy.test.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import Dummy from './Dummy';
-
-describe('Component: Dummy', () => {
- it('should render with dummy text', () => {
- render( );
- expect(screen.getByText('dummy')).toBeInTheDocument();
- });
-});
diff --git a/src/components/Dummy.tsx b/src/components/Dummy.tsx
deleted file mode 100644
index 6d7a6416..00000000
--- a/src/components/Dummy.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-// This is a dummy component for the test setup
-const Dummy = () => {
- return
dummy
;
-};
-
-export default Dummy;
diff --git a/src/components/FormContainer.tsx b/src/components/FormContainer.tsx
new file mode 100644
index 00000000..bd620675
--- /dev/null
+++ b/src/components/FormContainer.tsx
@@ -0,0 +1,13 @@
+import { ReactNode } from 'react';
+
+const FormContainer = ({ children }: { children: ReactNode }) => {
+ return (
+
+ );
+};
+
+export { FormContainer };
\ No newline at end of file
diff --git a/src/components/TextArea.tsx b/src/components/TextArea.tsx
new file mode 100644
index 00000000..605ee96d
--- /dev/null
+++ b/src/components/TextArea.tsx
@@ -0,0 +1,73 @@
+import { forwardRef } from 'react';
+import { Field, Label, Textarea } from '@headlessui/react';
+
+interface TextAreaProps extends React.TextareaHTMLAttributes {
+ label?: string;
+ error?: string;
+ description?: string;
+ value?: string;
+ required?: boolean;
+ onChange?: (e: React.ChangeEvent) => void;
+}
+
+const TextArea = forwardRef(
+ ({ label, error, description, className = '', value, required, onChange, ...props }, ref) => {
+ const inputProps = onChange
+ ? { value, onChange }
+ : { defaultValue: value };
+
+ const sharedClassNames = `
+ w-full px-4 py-2
+ bg-white
+ border border-gray-300
+ rounded-md
+ focus:outline-none focus:ring-2 focus:ring-blue-500
+ data-[invalid]:border-red-500
+ min-h-[100px] resize-y
+ ${className}
+ `;
+
+ return (
+
+
+ {label && (
+
+
+ {label}
+
+ {required && (
+
+ Required
+
+ )}
+
+ )}
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+ }
+);
+
+TextArea.displayName = 'TextArea';
+export { TextArea };
\ No newline at end of file
diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx
index e8777fd3..d8dbbbb0 100644
--- a/src/components/TextInput.tsx
+++ b/src/components/TextInput.tsx
@@ -6,43 +6,57 @@ interface TextInputProps extends React.InputHTMLAttributes {
error?: string;
description?: string;
value?: string;
+ required?: boolean;
onChange?: (e: React.ChangeEvent) => void;
}
const TextInput = forwardRef(
- ({ label, error, description, className = '', value, onChange, ...props }, ref) => {
+ ({ label, error, description, className = '', value, required, onChange, ...props }, ref) => {
const inputProps = onChange
? { value, onChange }
: { defaultValue: value };
+
+ const sharedClassNames = `
+ w-full px-4 py-3
+ bg-white
+ border border-gray-300
+ rounded-md
+ focus:outline-none focus:ring-2 focus:ring-blue-500
+ data-[invalid]:border-red-500
+ ${className}
+ `;
return (
-
+
{label && (
-
- {label}
-
+
+
+ {label}
+
+ {required && (
+
+ Required
+
+ )}
+
)}
+
+
{description && (
{description}
)}
+
{error && (
{error}
@@ -55,5 +69,4 @@ const TextInput = forwardRef
(
);
TextInput.displayName = 'TextInput';
-
-export { TextInput };
\ No newline at end of file
+export { TextInput };
diff --git a/src/components/index.ts b/src/components/index.ts
index 52520c33..498fe9a5 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,5 +1,7 @@
export { TextInput } from './TextInput';
+export { TextArea } from './TextArea';
export { Dropdown } from './Dropdown';
export { FileUpload } from './FileUpload';
export { InfoCard } from './InfoCard';
export { Button } from './Button';
+export { FormContainer } from './FormContainer';
\ No newline at end of file
diff --git a/src/components/tests/TextArea.test.tsx b/src/components/tests/TextArea.test.tsx
new file mode 100644
index 00000000..c523726b
--- /dev/null
+++ b/src/components/tests/TextArea.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TextArea } from '@components';
+
+describe('TextArea', () => {
+ it('renders with basic props', () => {
+ render();
+ expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
+ });
+
+ it('renders label when provided', () => {
+ render();
+ expect(screen.getByText('Description')).toBeInTheDocument();
+ });
+
+ it('shows required text when required prop is true', () => {
+ render();
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ it('displays error message', () => {
+ render();
+ expect(screen.getByText('This field is required')).toBeInTheDocument();
+ });
+
+ it('displays description text', () => {
+ render();
+ expect(screen.getByText('Enter detailed description')).toBeInTheDocument();
+ });
+
+ it('handles onChange events', async () => {
+ const handleChange = vi.fn();
+ render();
+
+ const textarea = screen.getByRole('textbox');
+ await userEvent.type(textarea, 'test');
+
+ expect(handleChange).toHaveBeenCalled();
+ expect(textarea).toHaveValue('test');
+ });
+
+ it('forwards ref correctly', () => {
+ const ref = vi.fn();
+ render();
+ expect(ref).toHaveBeenCalled();
+ });
+
+ it('applies custom className', () => {
+ render();
+ expect(screen.getByRole('textbox')).toHaveClass('custom-class');
+ });
+
+ it('allows resizing', () => {
+ render();
+ const textarea = screen.getByRole('textbox');
+ expect(textarea).toHaveClass('resize-y');
+ });
+
+ it('has minimum height', () => {
+ render();
+ const textarea = screen.getByRole('textbox');
+ expect(textarea).toHaveClass('min-h-[100px]');
+ });
+});
\ No newline at end of file
diff --git a/src/components/tests/TextInput.tsx b/src/components/tests/TextInput.tsx
new file mode 100644
index 00000000..2894fbc3
--- /dev/null
+++ b/src/components/tests/TextInput.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TextInput } from '@components';
+
+describe('TextInput', () => {
+ it('renders with basic props', () => {
+ render( );
+ expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
+ });
+
+ it('renders label when provided', () => {
+ render( );
+ expect(screen.getByText('Name')).toBeInTheDocument();
+ });
+
+ it('shows required text when required prop is true', () => {
+ render( );
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ it('displays error message', () => {
+ render( );
+ expect(screen.getByText('This field is required')).toBeInTheDocument();
+ });
+
+ it('displays description text', () => {
+ render( );
+ expect(screen.getByText('Enter your full name')).toBeInTheDocument();
+ });
+
+ it('handles onChange events', async () => {
+ const handleChange = vi.fn();
+ render( );
+
+ const input = screen.getByRole('textbox');
+ await userEvent.type(input, 'test');
+
+ expect(handleChange).toHaveBeenCalled();
+ expect(input).toHaveValue('test');
+ });
+
+ it('forwards ref correctly', () => {
+ const ref = vi.fn();
+ render( );
+ expect(ref).toHaveBeenCalled();
+ });
+
+ it('applies custom className', () => {
+ render( );
+ expect(screen.getByRole('textbox')).toHaveClass('custom-class');
+ });
+});
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
index b5c61c95..fb54bd32 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,9 @@
+@import url('https://fonts.googleapis.com/css2?family=Kanit:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap');
+
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+body {
+ font-family: 'Kanit', sans-serif;
+}
\ No newline at end of file
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx
new file mode 100644
index 00000000..096e5bef
--- /dev/null
+++ b/src/pages/Register.tsx
@@ -0,0 +1,300 @@
+import { useState, useEffect, FormEvent } from 'react';
+import { Button, TextInput, TextArea } from '@components';
+
+type RegistrationStep =
+ | 'login-register'
+ | 'verify-email'
+ | 'signing-in'
+ | 'form-details'
+ | 'registration-complete';
+
+interface FormData {
+ firstName: string;
+ lastName: string;
+ position: string;
+ bio: string;
+ linkedIn: string;
+ email: string;
+ password: string;
+}
+
+interface FormErrors {
+ linkedIn?: string;
+ email?: string;
+ password?: string;
+}
+
+const Register = () => {
+ const [currentStep, setCurrentStep] = useState('login-register');
+ const [formData, setFormData] = useState({
+ firstName: '',
+ lastName: '',
+ position: '',
+ bio: '',
+ linkedIn: '',
+ email: '',
+ password: ''
+ });
+ const [errors, setErrors] = useState({});
+
+ const LINKEDIN_REGEX = /^(https?:\/\/)?([\w]+\.)?linkedin\.com\/(pub|in|profile)\/([-a-zA-Z0-9]+)\/?$/;
+
+ const validateLinkedIn = (url: string): boolean => {
+ if (!url) return false;
+ return LINKEDIN_REGEX.test(url);
+ };
+
+ const handleLinkedInChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({
+ ...prev,
+ [name]: value
+ }));
+
+ if (value && !validateLinkedIn(value)) {
+ setErrors(prev => ({
+ ...prev,
+ linkedIn: "Please enter a valid LinkedIn profile URL"
+ }));
+ } else {
+ setErrors(prev => ({
+ ...prev,
+ linkedIn: undefined
+ }));
+ }
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({
+ ...prev,
+ [name]: value
+ }));
+
+ if (errors[name as keyof FormErrors]) {
+ setErrors(prev => ({
+ ...prev,
+ [name]: undefined
+ }));
+ }
+ };
+
+ const handleInitialSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ setCurrentStep('verify-email');
+ };
+
+ const handleFormSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ setCurrentStep('registration-complete');
+ };
+
+ const isFormDetailsValid = (): boolean => {
+ return (
+ formData.firstName.trim() !== '' &&
+ formData.lastName.trim() !== '' &&
+ formData.position.trim() !== '' &&
+ formData.bio.trim() !== '' &&
+ formData.linkedIn.trim() !== '' &&
+ !errors.linkedIn
+ );
+ };
+
+ const renderLoginRegister = () => (
+
+
Register or Login
+
+
Register for Spur+Konfer
+
+
+
+ );
+
+ const renderVerifyEmail = () => (
+
+
Verify your account
+
+ Your account and wallet has been linked. We just sent an email confirmation to {formData.email} . Please verify your account to continue registering.
+
+
+
+ Didn't get the email?
+ Resend Link
+
+
+ );
+
+ const renderSigningIn = () => {
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setCurrentStep('form-details');
+ }, 2000);
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+
+ );
+ };
+
+ const renderFormDetails = () => (
+
+
+
Welcome to Spur+Konfer
+
+ To begin your application, please enter your organization's details
+
+
+
+
+
+ );
+
+ const renderRegistrationComplete = () => {
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ // TODO: Push to '/dashboard'
+ }, 2000);
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+
+
+ Thank you for registering, you will now be redirected to the dashboard
+
+
+
+ );
+ };
+
+ const renderCurrentStep = () => {
+ switch (currentStep) {
+ case 'login-register':
+ return renderLoginRegister();
+ case 'verify-email':
+ return renderVerifyEmail();
+ case 'signing-in':
+ return renderSigningIn();
+ case 'form-details':
+ return renderFormDetails();
+ case 'registration-complete':
+ return renderRegistrationComplete();
+ }
+ };
+
+ return (
+
+
+ {renderCurrentStep()}
+
+
+ );
+};
+
+export { Register };
\ No newline at end of file
diff --git a/src/pages/index.ts b/src/pages/index.ts
index 61bb9687..cd53d11d 100644
--- a/src/pages/index.ts
+++ b/src/pages/index.ts
@@ -1 +1,2 @@
-export { Landing } from './Landing';
\ No newline at end of file
+export { Landing } from './Landing';
+export { Register } from './Register';
\ No newline at end of file
diff --git a/src/utils/Router.tsx b/src/utils/Router.tsx
index 5c7cd52d..b7e53d87 100644
--- a/src/utils/Router.tsx
+++ b/src/utils/Router.tsx
@@ -1,10 +1,11 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
-import { Landing } from '@pages'
+import { Landing, Register } from '@pages'
const Router = () => (
} />
+ } />
);
diff --git a/vitest.config.ts b/vitest.config.ts
index b446ddeb..2b7fa318 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,22 +1,32 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
+import path from 'path';
export default defineConfig({
- plugins: [react()],
- test: {
- environment: 'jsdom',
- globals: true,
- setupFiles: './src/test/setup.ts',
- coverage: {
- provider: 'v8',
- reporter: ['text', 'json', 'html'],
- exclude: [
- 'node_modules',
- 'src/test/setup.ts',
- 'vite.config.ts',
- 'eslint.config.js',
- 'vitest.config.ts',
- ],
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: './src/test/setup.ts',
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules',
+ 'src/test/setup.ts',
+ 'vite.config.ts',
+ 'eslint.config.js',
+ 'vitest.config.ts',
+ ],
+ },
},
- },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ '@assets': path.resolve(__dirname, './src/assets'),
+ '@components': path.resolve(__dirname, './src/components'),
+ '@pages': path.resolve(__dirname, './src/pages'),
+ '@utils': path.resolve(__dirname, './src/utils'),
+ }
+ }
});