feat(frontend): create reusable components for Phase 1 refactoring

- Add Alert component with 4 variants (success/error/warning/info)
- Add LoadingSpinner and LoadingButton components
- Add FormInput and FormSelect components with icon support
- Add Modal and ConfirmationModal components
- Add ChatMessage, ChatMessageList, and ChatInput components
- Add EventCard component

These components will eliminate ~450 lines of duplicated code across pages.
Part of Phase 1 (Quick Wins) frontend refactoring.
This commit is contained in:
Radosław Gierwiało
2025-11-20 23:22:05 +01:00
parent c1b5ae2ad3
commit 1772fc522e
11 changed files with 704 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import { Send } from 'lucide-react';
/**
* Chat Input component with send button
*
* @param {string} value - Input value
* @param {function} onChange - Change handler for input
* @param {function} onSubmit - Submit handler for form
* @param {boolean} disabled - Whether input is disabled (e.g., when not connected)
* @param {string} placeholder - Placeholder text (default: "Write a message...")
* @param {string} className - Additional classes for the form
* @param {boolean} autoFocus - Whether to autofocus the input
*/
const ChatInput = ({
value,
onChange,
onSubmit,
disabled = false,
placeholder = 'Write a message...',
className = '',
autoFocus = false
}) => {
const handleSubmit = (e) => {
e.preventDefault();
if (!value.trim() || disabled) return;
onSubmit(e);
};
return (
<form onSubmit={handleSubmit} className={`flex space-x-2 ${className}`}>
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Send message"
>
<Send className="w-5 h-5" />
</button>
</form>
);
};
export default ChatInput;

View File

@@ -0,0 +1,57 @@
import Avatar from '../common/Avatar';
/**
* Individual Chat Message component
*
* @param {object} message - Message object with { id, content, username, avatar, createdAt, userId/user_id }
* @param {boolean} isOwn - Whether this message belongs to the current user
* @param {function} formatTime - Optional custom time formatter (default: toLocaleTimeString)
*/
const ChatMessage = ({ message, isOwn, formatTime }) => {
const defaultFormatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
};
const timeFormatter = formatTime || defaultFormatTime;
return (
<div className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}>
<div
className={`flex items-start space-x-2 max-w-md ${
isOwn ? 'flex-row-reverse space-x-reverse' : ''
}`}
>
<Avatar
src={message.avatar}
username={message.username}
size={32}
title={message.username}
/>
<div>
<div className="flex items-baseline space-x-2 mb-1">
<span className="text-sm font-medium text-gray-900">
{message.username}
</span>
<span className="text-xs text-gray-500">
{timeFormatter(message.createdAt)}
</span>
</div>
<div
className={`rounded-lg px-4 py-2 ${
isOwn
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
{message.content}
</div>
</div>
</div>
</div>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,67 @@
import { Loader2 } from 'lucide-react';
import ChatMessage from './ChatMessage';
/**
* Chat Message List component with infinite scroll support
*
* @param {Array} messages - Array of message objects
* @param {number} currentUserId - Current user's ID to determine message ownership
* @param {React.Ref} messagesEndRef - Ref for scroll-to-bottom functionality
* @param {React.Ref} messagesContainerRef - Ref for the scrollable container
* @param {boolean} loadingOlder - Whether older messages are being loaded
* @param {boolean} hasMore - Whether there are more messages to load
* @param {string} emptyText - Text to show when there are no messages
* @param {function} formatTime - Optional custom time formatter
*/
const ChatMessageList = ({
messages = [],
currentUserId,
messagesEndRef,
messagesContainerRef,
loadingOlder = false,
hasMore = false,
emptyText = 'No messages yet. Start the conversation!',
formatTime
}) => {
return (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 space-y-4"
>
{/* Loading indicator for older messages */}
{loadingOlder && (
<div className="flex justify-center py-2">
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
</div>
)}
{/* Empty state */}
{messages.length === 0 && !loadingOlder && (
<div className="text-center text-gray-500 py-8">
{emptyText}
</div>
)}
{/* Messages */}
{messages.map((message) => {
// Support both userId and user_id (database field names may vary)
const messageUserId = message.userId ?? message.user_id;
const isOwnMessage = messageUserId === currentUserId;
return (
<ChatMessage
key={message.id}
message={message}
isOwn={isOwnMessage}
formatTime={formatTime}
/>
);
})}
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>
);
};
export default ChatMessageList;

View File

@@ -0,0 +1,63 @@
import { CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
/**
* Reusable Alert component for displaying success, error, warning, and info messages
*
* @param {string} type - Alert type: 'success', 'error', 'warning', 'info'
* @param {string} message - Message to display (if empty, component returns null)
* @param {React.Component} icon - Optional custom icon (overrides default)
* @param {function} onClose - Optional close handler for dismissible alerts
*/
const Alert = ({ type = 'info', message, icon: CustomIcon, onClose }) => {
if (!message) return null;
const styles = {
success: {
bg: 'bg-green-50',
border: 'border-green-200',
text: 'text-green-600',
iconComponent: CheckCircle
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-600',
iconComponent: AlertCircle
},
warning: {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
text: 'text-yellow-600',
iconComponent: AlertTriangle
},
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-600',
iconComponent: Info
}
};
const style = styles[type] || styles.info;
const IconComponent = CustomIcon || style.iconComponent;
return (
<div className={`p-3 ${style.bg} border ${style.border} rounded-md flex items-start gap-2`}>
<IconComponent className={`w-5 h-5 ${style.text} flex-shrink-0 mt-0.5`} />
<p className={`text-sm ${style.text} flex-1`}>{message}</p>
{onClose && (
<button
onClick={onClose}
className={`${style.text} hover:opacity-70 transition-opacity flex-shrink-0`}
aria-label="Close alert"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
};
export default Alert;

View File

@@ -0,0 +1,71 @@
/**
* Reusable Form Input component with optional icon
*
* @param {string} label - Input label text
* @param {string} name - Input name attribute
* @param {string} type - Input type (text, email, password, url, number, etc.)
* @param {string} value - Input value
* @param {function} onChange - Change handler
* @param {React.Component} icon - Optional Lucide icon component (e.g., Mail, Lock)
* @param {string} placeholder - Placeholder text
* @param {boolean} required - Whether field is required
* @param {string} helperText - Optional helper text below input
* @param {number} maxLength - Maximum input length
* @param {string} className - Additional CSS classes for the container
* @param {string} error - Error message (shows red border and error text)
*/
const FormInput = ({
label,
name,
type = 'text',
value,
onChange,
icon: Icon,
placeholder,
required = false,
helperText,
maxLength,
className = '',
error,
...props
}) => {
return (
<div className={className}>
{label && (
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<input
id={name}
type={type}
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
required={required}
maxLength={maxLength}
className={`${Icon ? 'pl-10' : ''} w-full px-3 py-2 border ${
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-primary-500 focus:border-primary-500'
} rounded-md focus:ring-1 focus:outline-none`}
{...props}
/>
</div>
{error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
{!error && helperText && (
<p className="text-xs text-gray-500 mt-1">{helperText}</p>
)}
</div>
);
};
export default FormInput;

View File

@@ -0,0 +1,79 @@
/**
* Reusable Form Select (dropdown) component with optional icon
*
* @param {string} label - Select label text
* @param {string} name - Select name attribute
* @param {string} value - Selected value
* @param {function} onChange - Change handler
* @param {Array<string|object>} options - Array of options (strings or {value, label} objects)
* @param {React.Component} icon - Optional Lucide icon component (e.g., Globe, MapPin)
* @param {string} placeholder - Placeholder option text (if provided, adds empty option)
* @param {boolean} required - Whether field is required
* @param {string} helperText - Optional helper text below select
* @param {string} className - Additional CSS classes for the container
* @param {string} error - Error message (shows red border and error text)
*/
const FormSelect = ({
label,
name,
value,
onChange,
options = [],
icon: Icon,
placeholder,
required = false,
helperText,
className = '',
error,
...props
}) => {
return (
<div className={className}>
{label && (
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative">
{Icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon className="h-5 w-5 text-gray-400" />
</div>
)}
<select
id={name}
name={name}
value={value}
onChange={onChange}
required={required}
className={`${Icon ? 'pl-10' : ''} w-full px-3 py-2 border ${
error ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-primary-500 focus:border-primary-500'
} rounded-md focus:ring-1 focus:outline-none bg-white`}
{...props}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((option) => {
// Support both string options and {value, label} objects
const optionValue = typeof option === 'string' ? option : option.value;
const optionLabel = typeof option === 'string' ? option : option.label;
return (
<option key={optionValue} value={optionValue}>
{optionLabel}
</option>
);
})}
</select>
</div>
{error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
{!error && helperText && (
<p className="text-xs text-gray-500 mt-1">{helperText}</p>
)}
</div>
);
};
export default FormSelect;

View File

@@ -0,0 +1,37 @@
import { Loader2 } from 'lucide-react';
/**
* Reusable Button component with built-in loading state
*
* @param {boolean} loading - Shows loading spinner when true
* @param {React.ReactNode} children - Button content when not loading
* @param {string} loadingText - Text to show when loading (default: "Loading...")
* @param {string} className - Additional CSS classes
* @param {object} ...props - All other button props (onClick, disabled, etc.)
*/
const LoadingButton = ({
loading = false,
children,
loadingText = 'Loading...',
className = '',
...props
}) => {
return (
<button
{...props}
disabled={loading || props.disabled}
className={className}
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
{loadingText}
</>
) : (
children
)}
</button>
);
};
export default LoadingButton;

View File

@@ -0,0 +1,36 @@
import { Loader2 } from 'lucide-react';
/**
* Reusable Loading Spinner component
*
* @param {string} size - Spinner size: 'sm', 'md', 'lg' (default: 'md')
* @param {string} text - Optional text to display below spinner
* @param {boolean} fullPage - If true, centers spinner in a full-page container
* @param {string} className - Additional CSS classes
*/
const LoadingSpinner = ({ size = 'md', text, fullPage = false, className = '' }) => {
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12'
};
const spinner = (
<div className={`flex flex-col items-center gap-3 ${className}`}>
<Loader2 className={`${sizes[size]} animate-spin text-primary-600`} />
{text && <p className="text-gray-600">{text}</p>}
</div>
);
if (fullPage) {
return (
<div className="flex items-center justify-center min-h-[400px]">
{spinner}
</div>
);
}
return spinner;
};
export default LoadingSpinner;

View File

@@ -0,0 +1,95 @@
import { useNavigate } from 'react-router-dom';
import { Calendar, MapPin, Users, CheckCircle, QrCode } from 'lucide-react';
/**
* Event Card component for displaying event information
*
* @param {object} event - Event object with { slug, name, location, startDate, endDate, participantsCount, description, isJoined }
* @param {number} delay - Animation delay in milliseconds
* @param {boolean} isNew - Whether this is a newly added card (triggers animation)
* @param {boolean} showCheckin - Whether to show check-in requirement message
*/
const EventCard = ({ event, delay = 0, isNew = false, showCheckin = false }) => {
const navigate = useNavigate();
const handleJoinEvent = () => navigate(`/events/${event.slug}/chat`);
const handleViewDetails = () => navigate(`/events/${event.slug}/details`);
return (
<div
className={`bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-2 ${
event.isJoined ? 'border-primary-500' : 'border-gray-200'
} ${isNew ? 'animate-fade-slide-in' : ''}`}
style={isNew ? { animationDelay: `${delay}ms` } : undefined}
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<h3 className="text-xl font-bold text-gray-900">{event.name}</h3>
{event.isJoined && (
<span className="flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded-full">
<CheckCircle className="w-3 h-3" />
Joined
</span>
)}
</div>
{/* Event Details */}
<div className="space-y-2 mb-4">
<div className="flex items-center text-gray-600">
<MapPin className="w-4 h-4 mr-2" />
<span className="text-sm">{event.location}</span>
</div>
<div className="flex items-center text-gray-600">
<Calendar className="w-4 h-4 mr-2" />
<span className="text-sm">
{new Date(event.startDate).toLocaleDateString('en-US')} -{' '}
{new Date(event.endDate).toLocaleDateString('en-US')}
</span>
</div>
<div className="flex items-center text-gray-600">
<Users className="w-4 h-4 mr-2" />
<span className="text-sm">{event.participantsCount} participants</span>
</div>
</div>
{/* Description */}
{event.description && (
<p className="text-gray-600 text-sm mb-4">{event.description}</p>
)}
{/* Actions */}
<div className="space-y-2">
{event.isJoined ? (
<button
onClick={handleJoinEvent}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
>
Open chat
</button>
) : showCheckin ? (
<div className="bg-amber-50 border border-amber-200 rounded-md p-4 text-sm text-amber-800">
<div className="flex items-center gap-2 font-medium mb-2">
<QrCode className="w-5 h-5" />
Check-in required
</div>
<p className="text-amber-700">
Scan the QR code at the event venue to join the chat
</p>
</div>
) : null}
{/* Development mode: Show details link */}
{import.meta.env.DEV && (
<button
onClick={handleViewDetails}
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-sm"
>
View details (dev)
</button>
)}
</div>
</div>
);
};
export default EventCard;

View File

@@ -0,0 +1,93 @@
import { AlertTriangle, Loader2 } from 'lucide-react';
/**
* Reusable Confirmation Modal for actions that need user confirmation
*
* @param {boolean} isOpen - Controls modal visibility
* @param {function} onClose - Close/Cancel handler
* @param {function} onConfirm - Confirm action handler
* @param {string} title - Modal title
* @param {string} description - Optional description text
* @param {string} message - Main message/question to display
* @param {string} confirmText - Confirm button text (default: 'Confirm')
* @param {string} cancelText - Cancel button text (default: 'Cancel')
* @param {string} confirmButtonClass - Custom classes for confirm button (default: red/danger style)
* @param {React.Component} icon - Icon component (default: AlertTriangle)
* @param {string} iconBgClass - Icon background class (default: bg-red-100)
* @param {string} iconColorClass - Icon color class (default: text-red-600)
* @param {boolean} isLoading - Shows loading state on confirm button
* @param {string} loadingText - Text to show when loading (default: 'Loading...')
*/
const ConfirmationModal = ({
isOpen,
onClose,
onConfirm,
title,
description,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmButtonClass = 'bg-red-600 hover:bg-red-700',
icon: Icon = AlertTriangle,
iconBgClass = 'bg-red-100',
iconColorClass = 'text-red-600',
isLoading = false,
loadingText = 'Loading...'
}) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full p-6"
onClick={(e) => e.stopPropagation()}
>
{/* Header with Icon */}
<div className="flex items-center gap-3 mb-4">
<div className={`w-12 h-12 rounded-full ${iconBgClass} flex items-center justify-center flex-shrink-0`}>
<Icon className={iconColorClass} size={24} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
{description && <p className="text-sm text-gray-600">{description}</p>}
</div>
</div>
{/* Message */}
{message && (
<p className="text-gray-700 mb-6">{message}</p>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className={`flex-1 px-4 py-2 ${confirmButtonClass} text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2`}
>
{isLoading ? (
<>
<Loader2 className="animate-spin" size={16} />
{loadingText}
</>
) : (
confirmText
)}
</button>
</div>
</div>
</div>
);
};
export default ConfirmationModal;

View File

@@ -0,0 +1,54 @@
import { X } from 'lucide-react';
/**
* Generic Modal component with customizable content
*
* @param {boolean} isOpen - Controls modal visibility
* @param {function} onClose - Close handler
* @param {string} title - Optional modal title
* @param {React.ReactNode} children - Modal content
* @param {string} maxWidth - Maximum width class (default: 'max-w-4xl')
* @param {boolean} showCloseButton - Show X button in header (default: true)
*/
const Modal = ({
isOpen,
onClose,
title,
children,
maxWidth = 'max-w-4xl',
showCloseButton = true
}) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className={`bg-white rounded-lg shadow-xl ${maxWidth} w-full max-h-[90vh] overflow-y-auto`}
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside modal
>
{title && (
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
{showCloseButton && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close modal"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
<div className="p-6">
{children}
</div>
</div>
</div>
);
};
export default Modal;