feat: implement Fight registration form with input validation and file upload
This commit is contained in:
@@ -47,7 +47,7 @@ export default function Checkbox({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 w-full max-w-sm ${className}`}>
|
<div className={`flex flex-col gap-2 w-full ${className}`}>
|
||||||
<div
|
<div
|
||||||
className={`flex gap-2 ${
|
className={`flex gap-2 ${
|
||||||
direction === "vertical" ? "flex-col" : "flex-row flex-wrap"
|
direction === "vertical" ? "flex-col" : "flex-row flex-wrap"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { gothampro } from "@/utils/fonts";
|
import { gothampro, fluxgore } from "@/utils/fonts";
|
||||||
import React, { useState, useRef, DragEvent, ChangeEvent } from "react";
|
import React, { useState, useRef, DragEvent, ChangeEvent } from "react";
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
@@ -6,6 +6,7 @@ interface FileUploadProps {
|
|||||||
onFileSelect?: (files: File[]) => void;
|
onFileSelect?: (files: File[]) => void;
|
||||||
acceptedTypes?: string[];
|
acceptedTypes?: string[];
|
||||||
maxFileSize?: number; // in MB
|
maxFileSize?: number; // in MB
|
||||||
|
maxFiles?: number; // maximum number of files
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -19,6 +20,7 @@ function Fileupload({
|
|||||||
onFileSelect,
|
onFileSelect,
|
||||||
acceptedTypes = ["image/*", "application/pdf", ".doc", ".docx"],
|
acceptedTypes = ["image/*", "application/pdf", ".doc", ".docx"],
|
||||||
maxFileSize = 10,
|
maxFileSize = 10,
|
||||||
|
maxFiles = 5,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
placeholder = "Файл в формате jpg или png до N мб",
|
placeholder = "Файл в формате jpg или png до N мб",
|
||||||
@@ -67,15 +69,41 @@ function Fileupload({
|
|||||||
if (!files) return;
|
if (!files) return;
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
const { valid, errors } = validateFiles(fileArray);
|
const { valid, errors: validationErrors } = validateFiles(fileArray);
|
||||||
|
const allErrors = [...validationErrors];
|
||||||
setErrors(errors);
|
|
||||||
|
|
||||||
if (valid.length > 0) {
|
if (valid.length > 0) {
|
||||||
const newFiles = multiple ? [...selectedFiles, ...valid] : valid;
|
let newFiles: File[];
|
||||||
|
|
||||||
|
if (multiple) {
|
||||||
|
// Check if adding new files would exceed maxFiles limit
|
||||||
|
const totalFiles = selectedFiles.length + valid.length;
|
||||||
|
if (totalFiles > maxFiles) {
|
||||||
|
const allowedCount = maxFiles - selectedFiles.length;
|
||||||
|
if (allowedCount <= 0) {
|
||||||
|
allErrors.push(
|
||||||
|
`Maximum ${maxFiles} files allowed. Remove some files first.`
|
||||||
|
);
|
||||||
|
setErrors(allErrors);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
allErrors.push(
|
||||||
|
`Only ${allowedCount} more files can be added (max ${maxFiles} total).`
|
||||||
|
);
|
||||||
|
newFiles = [...selectedFiles, ...valid.slice(0, allowedCount)];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newFiles = [...selectedFiles, ...valid];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newFiles = valid;
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedFiles(newFiles);
|
setSelectedFiles(newFiles);
|
||||||
onFileSelect?.(newFiles);
|
onFileSelect?.(newFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setErrors(allErrors);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrag = (e: DragEvent<HTMLDivElement>) => {
|
const handleDrag = (e: DragEvent<HTMLDivElement>) => {
|
||||||
@@ -116,6 +144,10 @@ function Fileupload({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if upload button should be disabled
|
||||||
|
const isUploadDisabled =
|
||||||
|
disabled || (multiple && selectedFiles.length >= maxFiles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
@@ -131,10 +163,12 @@ function Fileupload({
|
|||||||
{/* Upload Area */}
|
{/* Upload Area */}
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex items-center border rounded-md overflow-hidden transition-colors
|
flex items-center overflow-hidden transition-colors
|
||||||
${dragActive ? "border-blue-400 bg-blue-50" : "border-gray-300"}
|
${dragActive ? "border-[#1068B0] bg-[#1068B0]/10" : "border-gray-300"}
|
||||||
${
|
${
|
||||||
disabled ? "opacity-50 cursor-not-allowed" : "hover:border-blue-400"
|
isUploadDisabled
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: "hover:border-[#1068B0]"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
onDragEnter={handleDrag}
|
onDragEnter={handleDrag}
|
||||||
@@ -149,7 +183,7 @@ function Fileupload({
|
|||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
accept={acceptedTypes.join(",")}
|
accept={acceptedTypes.join(",")}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
disabled={disabled}
|
disabled={isUploadDisabled}
|
||||||
id={id}
|
id={id}
|
||||||
name={name}
|
name={name}
|
||||||
/>
|
/>
|
||||||
@@ -158,8 +192,8 @@ function Fileupload({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openFileDialog}
|
onClick={openFileDialog}
|
||||||
disabled={disabled}
|
disabled={isUploadDisabled}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 text-sm font-medium uppercase tracking-wide disabled:opacity-50"
|
className={`${fluxgore.className} bg-[#1068B0] hover:bg-[#0d5a96] text-white px-9 py-4 text-base font-medium uppercase tracking-wide disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
ПРИКРЕПИТЬ
|
ПРИКРЕПИТЬ
|
||||||
</button>
|
</button>
|
||||||
@@ -167,9 +201,14 @@ function Fileupload({
|
|||||||
{/* File Display Area */}
|
{/* File Display Area */}
|
||||||
<div className="flex-1 px-3 py-2 min-h-[40px] flex items-center">
|
<div className="flex-1 px-3 py-2 min-h-[40px] flex items-center">
|
||||||
{selectedFiles.length > 0 ? (
|
{selectedFiles.length > 0 ? (
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-900 truncate">
|
<span className="text-sm text-gray-900 truncate">
|
||||||
{selectedFiles[0].name}
|
{selectedFiles[0].name}
|
||||||
|
{multiple && selectedFiles.length > 1 && (
|
||||||
|
<span className="text-gray-500 ml-1">
|
||||||
|
and {selectedFiles.length - 1} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -195,13 +234,20 @@ function Fileupload({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-500">
|
<span className={`${gothampro.className} text-sm text-gray-500`}>
|
||||||
{placeholder.replace("N", maxFileSize.toString())}
|
{placeholder.replace("N", maxFileSize.toString())}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File count indicator for multiple files */}
|
||||||
|
{multiple && selectedFiles.length > 0 && (
|
||||||
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
{selectedFiles.length} of {maxFiles} files selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error Messages */}
|
{/* Error Messages */}
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="mt-2 text-sm text-red-600">
|
<div className="mt-2 text-sm text-red-600">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function Input({
|
|||||||
name,
|
name,
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 w-full max-w-sm ${className}`}>
|
<div className={`flex flex-col gap-2 w-full ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={id || name}
|
htmlFor={id || name}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function Radio({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 w-full max-w-sm ${className}`}>
|
<div className={`flex flex-col gap-2 w-full ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
className={`${gothampro.className} text-base font-bold text-black`}
|
className={`${gothampro.className} text-base font-bold text-black`}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function Select({
|
|||||||
name,
|
name,
|
||||||
}: SelectProps) {
|
}: SelectProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 w-full max-w-sm ${className}`}>
|
<div className={`flex flex-col gap-2 w-full ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={id || name}
|
htmlFor={id || name}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function Textarea({
|
|||||||
}[resize];
|
}[resize];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-2 w-full max-w-sm ${className}`}>
|
<div className={`flex flex-col gap-2 w-full ${className}`}>
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={id || name}
|
htmlFor={id || name}
|
||||||
|
|||||||
@@ -1,92 +1,100 @@
|
|||||||
import Checkbox from "@/components/form/Checkbox";
|
import Checkbox from "@/components/form/Checkbox";
|
||||||
import Fileupload from "@/components/form/Fileupload";
|
import Fileupload from "@/components/form/Fileupload";
|
||||||
import Input from "@/components/form/Input";
|
import Input from "@/components/form/Input";
|
||||||
import Radio from "@/components/form/Radio";
|
|
||||||
import Select from "@/components/form/Select";
|
|
||||||
import Textarea from "@/components/form/Textarea";
|
import Textarea from "@/components/form/Textarea";
|
||||||
import { fluxgore, gothampro } from "@/utils/fonts";
|
import { fluxgore, gothampro } from "@/utils/fonts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
function ExhibtionFormPage() {
|
function ExhibtionFormPage() {
|
||||||
const [selectedOption, setSelectedOption] = useState("online");
|
|
||||||
const [checkboxValues, setCheckboxValues] = useState<string[]>([]);
|
const [checkboxValues, setCheckboxValues] = useState<string[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#ffffff] relative h-full">
|
<div className="bg-[#ffffff] relative p-6 md:p-8 lg:p-12 flex flex-col items-center justify-center md:h-full">
|
||||||
<h1
|
<h1
|
||||||
className={`${fluxgore.className} text-4xl md:text-7xl text-[#060606] relative`}
|
className={`${fluxgore.className} text-4xl md:text-7xl text-[#060606] relative mb-8 md:mb-12`}
|
||||||
>
|
>
|
||||||
Регистрация участника
|
Регистрация на выставку
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Input
|
<div className="space-y-6 max-w-2xl w-full">
|
||||||
label="Имя"
|
<Input
|
||||||
placeholder="Введите ваше имя"
|
label="Имя"
|
||||||
type="text"
|
placeholder="Введите ваше имя"
|
||||||
required
|
type="text"
|
||||||
className="mt-4"
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<Input
|
||||||
label="Выберите тип участия"
|
label="Телефон"
|
||||||
options={[
|
placeholder="+7 (___) ___-__-__"
|
||||||
{ value: "speaker", label: "Спикер" },
|
type="tel"
|
||||||
{ value: "attendee", label: "Посетитель" },
|
required
|
||||||
{ value: "sponsor", label: "Спонсор" },
|
/>
|
||||||
]}
|
|
||||||
required
|
|
||||||
className="mt-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Radio
|
<Input
|
||||||
value={selectedOption}
|
label="Почта"
|
||||||
onChange={setSelectedOption}
|
placeholder="example@email.com"
|
||||||
label="Выберите формат участия"
|
type="email"
|
||||||
options={[
|
required
|
||||||
{ value: "online", label: "Онлайн" },
|
/>
|
||||||
{ value: "offline", label: "Офлайн" },
|
|
||||||
{ value: "hybrid", label: "Гибридный" },
|
|
||||||
]}
|
|
||||||
required
|
|
||||||
className="mt-4"
|
|
||||||
direction="vertical"
|
|
||||||
name="participationFormat"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Textarea
|
<Input
|
||||||
label="Дополнительная информация"
|
label="Марка автомобиля"
|
||||||
placeholder="Введите дополнительную информацию"
|
placeholder="Введите марку автомобиля"
|
||||||
rows={4}
|
type="text"
|
||||||
cols={50}
|
required
|
||||||
className="mt-4"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
<Input
|
||||||
value={checkboxValues}
|
label="Модель"
|
||||||
onChange={(values) => setCheckboxValues(values)}
|
placeholder="Введите модель автомобиля"
|
||||||
options={[
|
type="text"
|
||||||
{
|
required
|
||||||
value: "terms",
|
/>
|
||||||
label: (
|
|
||||||
<label className={`${gothampro.className} text-base text-black`}>
|
|
||||||
Согласие на обработку персональных данных
|
|
||||||
</label>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
required
|
|
||||||
className="mt-4"
|
|
||||||
direction="vertical"
|
|
||||||
name="termsAgreement"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Fileupload
|
<Fileupload
|
||||||
label="Загрузите файлы"
|
label="Фото автомобиля (до 3 файлов)"
|
||||||
onFileSelect={(files) => console.log("Selected files:", files)}
|
onFileSelect={(files) => console.log("Selected files:", files)}
|
||||||
acceptedTypes={["image/*", "application/pdf"]}
|
acceptedTypes={["image/*"]}
|
||||||
maxFileSize={5}
|
maxFileSize={5}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
maxFiles={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Интересное об автомобиле"
|
||||||
|
placeholder="Расскажите что-то интересное о вашем автомобиле"
|
||||||
|
rows={4}
|
||||||
|
cols={50}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
value={checkboxValues}
|
||||||
|
onChange={(values) => setCheckboxValues(values)}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "terms",
|
||||||
|
label: (
|
||||||
|
<label
|
||||||
|
className={`${gothampro.className} text-base text-black`}
|
||||||
|
>
|
||||||
|
Согласие на обработку персональных данных
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
direction="vertical"
|
||||||
|
name="termsAgreement"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`${fluxgore.className} bg-[#1068B0] hover:bg-[#0d5a96] text-white px-9 py-4 text-base font-medium uppercase tracking-wide disabled:opacity-50 w-full`}
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Checkbox from "@/components/form/Checkbox";
|
||||||
|
import Fileupload from "@/components/form/Fileupload";
|
||||||
|
import Input from "@/components/form/Input";
|
||||||
|
import Textarea from "@/components/form/Textarea";
|
||||||
|
import { fluxgore, gothampro } from "@/utils/fonts";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function FightFormPage() {
|
||||||
|
const [checkboxValues, setCheckboxValues] = useState<string[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#ffffff] relative p-6 md:p-8 lg:p-12 flex flex-col items-center justify-center">
|
||||||
|
<h1
|
||||||
|
className={`${fluxgore.className} text-4xl md:text-7xl text-[#060606] relative mb-8 md:mb-12`}
|
||||||
|
>
|
||||||
|
Регистрация на Битву за Москву
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6 max-w-2xl w-full">
|
||||||
|
<Input
|
||||||
|
label="Фамилия"
|
||||||
|
placeholder="Введите вашу фамилию"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Имя"
|
||||||
|
placeholder="Введите ваше имя"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Отчество"
|
||||||
|
placeholder="Введите ваше отчество"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Дата рождения"
|
||||||
|
placeholder="дд.мм.гггг"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Гражданство"
|
||||||
|
placeholder="Введите ваше гражданство"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Телефон"
|
||||||
|
placeholder="+7 (___) ___-__-__"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Почта"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Марка автомобиля"
|
||||||
|
placeholder="Введите марку автомобиля"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Модель"
|
||||||
|
placeholder="Введите модель автомобиля"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Двигатель"
|
||||||
|
placeholder="Введите тип двигателя"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Мощность"
|
||||||
|
placeholder="Введите мощность (л.с.)"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Fileupload
|
||||||
|
label="Фото автомобиля (до 3 файлов)"
|
||||||
|
onFileSelect={(files) => console.log("Selected files:", files)}
|
||||||
|
acceptedTypes={["image/*"]}
|
||||||
|
maxFileSize={5}
|
||||||
|
multiple={true}
|
||||||
|
maxFiles={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Дополнительная информация"
|
||||||
|
placeholder="Дополнительная информация об автомобиле"
|
||||||
|
rows={4}
|
||||||
|
cols={50}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
value={checkboxValues}
|
||||||
|
onChange={(values) => setCheckboxValues(values)}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "terms",
|
||||||
|
label: (
|
||||||
|
<label
|
||||||
|
className={`${gothampro.className} text-base text-black`}
|
||||||
|
>
|
||||||
|
Согласие на обработку персональных данных
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
direction="vertical"
|
||||||
|
name="termsAgreement"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`${fluxgore.className} bg-[#1068B0] hover:bg-[#0d5a96] text-white px-9 py-4 text-base font-medium uppercase tracking-wide disabled:opacity-50 w-full`}
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FightFormPage;
|
||||||
Reference in New Issue
Block a user