feat / AEB-26 login page

This commit is contained in:
Timofey Syrokvashko
2025-09-01 11:34:30 +03:00
parent 38a31824bc
commit 65a63235e5
37 changed files with 1185 additions and 49 deletions

View File

@@ -0,0 +1,34 @@
import React from 'react'
import { ButtonProps } from '@/app/types'
const Button = ({
onClick,
className,
text,
type,
leftIcon,
midIcon,
rightIcon,
size = 'lg',
}: ButtonProps) => {
const sizeClasses = {
sm: 'h-10 text-sm',
md: 'h-12 text-base',
lg: 'h-14 text-xl',
}
return (
<button
onClick={onClick}
className={`cursor-pointer rounded-xl transition-all duration-500 hover:shadow-2xl ${sizeClasses[size]} ${className}`}
type={type}
>
{leftIcon && <span className="mr-2 flex items-center">{leftIcon}</span>}
{midIcon && <span className="flex items-center">{midIcon}</span>}
<span className="text-center font-normal">{text}</span>
{rightIcon && <span className="ml-2 flex items-center">{rightIcon}</span>}
</button>
)
}
export default Button

View File

@@ -0,0 +1,21 @@
import React from 'react'
const Loader = () => {
return (
<div className="flex items-center justify-center p-8">
<div className="relative h-12 w-12">
{/* пульсирующие круги */}
<div className="bg-blue/20 absolute inset-0 animate-ping rounded-full"></div>
<div
className="bg-blue/40 absolute inset-2 animate-ping rounded-full"
style={{ animationDelay: '0.2s' }}
></div>
<div className="bg-blue absolute inset-4 animate-pulse rounded-full"></div>
{/* бегущий блик */}
<div className="bg-blue-to-r animate-shimmer absolute inset-0 rounded-full from-transparent via-white/20 to-transparent"></div>
</div>
</div>
)
}
export default Loader

View File

@@ -1,9 +1,190 @@
import React from 'react'
'use client'
import React, { useState } from 'react'
import Select from 'react-select'
import Tooltip from './Tooltip'
import { useClientFetch } from '@/app/hooks/useClientFetch'
export interface Option {
id: number
value: string
label: string
}
export interface DataItem {
id: number
[key: string]: unknown
}
export interface PaginatedResponse<T> {
results: T[]
count: number
next: string | null
previous: string | null
}
export interface SelectorProps<T extends DataItem> {
name: string
value?: Option | null
handleChange: (e: {
target: {
id: string
value: Option | null
selectedOption?: Option
}
}) => void
label?: string
tooltip?: string | React.ReactNode
placeholder?: string
endpoint?: string
mapDataToOptions: (data: T) => Option
searchParam?: string
staticOptions?: T[]
config?: {
params?: Record<string, string | number | boolean | undefined>
queryOptions?: {
enabled?: boolean
}
}
}
const Selector = <T extends DataItem>({
name,
value,
handleChange,
label,
tooltip,
placeholder,
endpoint,
mapDataToOptions,
searchParam = 'search',
staticOptions,
config = {},
}: SelectorProps<T>) => {
const [search, setSearch] = useState('')
const { data, isLoading, error } = useClientFetch<PaginatedResponse<T>>(
endpoint || '/404-no-endpoint',
{
config: {
params: {
...(search ? { [searchParam]: search } : {}),
...(config.params || {}),
},
},
queryOptions: {
staleTime: 60 * 60 * 1000, // кешируем на час
refetchOnWindowFocus: false,
enabled: !staticOptions && endpoint !== undefined && config.queryOptions?.enabled !== false, // отключаем запрос если используем статические опции, нет endpoint или явно выключен в конфиге
},
}
)
let options: Option[] = []
if (staticOptions) {
// если есть статические опции используем их
options = staticOptions.map(mapDataToOptions)
} else {
// иначе используем данные с бекенда
const dataArray = Array.isArray(data) ? data : data?.results || []
options = dataArray.map(mapDataToOptions)
}
if (error) {
console.error(`Error fetching data from ${endpoint}:`, error)
}
const Selector = () => {
return (
<div>Selector</div>
<div>
{label && (
<div className="my-2 flex items-center gap-2">
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
{label}
</label>
{tooltip && <Tooltip content={tooltip} />}
</div>
)}
<Select<Option>
inputId={name}
name={name}
options={options}
value={options.find(opt => opt.id === value?.id)}
onChange={selectedOption => {
handleChange({
target: {
id: name,
value: selectedOption
? {
...selectedOption,
}
: null,
selectedOption: selectedOption || undefined,
},
})
}}
isLoading={isLoading}
onInputChange={newValue => setSearch(newValue)}
isSearchable
isClearable
placeholder={placeholder}
noOptionsMessage={() => (isLoading ? 'Загрузка...' : 'Нет доступных вариантов')}
classNamePrefix="select"
className="rounded-lg"
styles={{
control: base => ({
...base,
borderRadius: '0.75rem',
backgroundColor: '#1B1E28',
border: '1px solid #E5E7EB',
padding: '2px',
color: 'white',
'&:hover': {
borderColor: '#E5E7EB',
},
'&:focus-within': {
backgroundColor: '#1B1E28',
borderColor: '#E5E7EB',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.5)',
},
}),
singleValue: base => ({
...base,
color: 'white',
}),
input: base => ({
...base,
color: 'white',
}),
menu: base => ({
...base,
position: 'absolute',
width: '100%',
zIndex: 9999,
marginTop: '4px',
borderRadius: '0.75rem',
overflow: 'hidden',
backgroundColor: '#1B1E28',
border: '1px solid #E5E7EB',
}),
option: (base, state) => ({
...base,
fontSize: '0.875rem',
padding: '8px 12px',
backgroundColor: state.isSelected ? '#2563EB' : state.isFocused ? '#2D3139' : '#1B1E28',
color: 'white',
cursor: 'pointer',
'&:active': {
backgroundColor: '#2D3139',
},
'&:hover': {
backgroundColor: state.isSelected ? '#2563EB' : '#2D3139',
},
}),
}}
/>
</div>
)
}
export default Selector
export default Selector

View File

@@ -1,9 +1,76 @@
import React from 'react'
import { TextInputProps } from '@/app/types'
import { HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi'
const TextInput = ({
value,
handleChange,
label,
placeholder,
name,
type = 'text',
className = '',
maxLength,
min,
tooltip,
style,
isPassword,
togglePasswordVisibility,
isVisible,
error,
}: TextInputProps) => {
const getStylesProps = () => {
const baseStyles = 'px-3 py-2 '
switch (style) {
case 'main':
return `p-4`
case 'register':
return `${baseStyles}`
default:
return baseStyles
}
}
const TextInput = () => {
return (
<div>TextInput</div>
<div className={className}>
{label && (
<div className="my-2 flex items-center gap-2">
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
{label}
</label>
{tooltip && <div className="tooltip">{tooltip}</div>}
</div>
)}
<div className="relative">
<input
type={isPassword ? (isVisible ? 'text' : 'password') : type}
id={name}
name={name}
placeholder={placeholder}
value={value || ''}
onChange={handleChange}
className={`${getStylesProps()} w-full border bg-[#1B1E28] text-white ${
error ? 'border-red-500' : 'border-gray-300'
} rounded-xl focus:ring-1 focus:outline-none ${
error ? 'focus:ring-red-400' : 'focus:ring-blue-400'
}`}
autoComplete={name}
maxLength={maxLength}
min={min}
/>
{isPassword && (
<button
type="button"
onClick={togglePasswordVisibility}
className="absolute top-1/2 right-3 -translate-y-1/2 text-white hover:text-gray-700"
>
{isVisible ? <HiOutlineEye /> : <HiOutlineEyeOff />}
</button>
)}
</div>
{error && <div className="mt-1 text-sm text-red-500">{error}</div>}
</div>
)
}
export default TextInput
export default TextInput

View File

@@ -0,0 +1,34 @@
'use client'
import { useState } from 'react'
import { CiCircleInfo } from 'react-icons/ci'
interface TooltipProps {
content: string | React.ReactNode
}
const Tooltip = ({ content }: TooltipProps) => {
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="relative flex items-center overflow-visible">
<button
type="button"
className="text-orange hover:text-orange/80 focus:outline-none"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onClick={() => setShowTooltip(!showTooltip)}
>
<CiCircleInfo className="h-4 w-4" />
</button>
{showTooltip && (
<div className="absolute bottom-full left-1/2 z-10 mb-3 w-max max-w-xs -translate-x-1/2 rounded-xl bg-white px-4 py-2 text-center text-sm text-gray-700 shadow-lg">
{content}
<div className="absolute -bottom-2 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 transform bg-white"></div>
</div>
)}
</div>
)
}
export default Tooltip