feat: implement Fight registration form with input validation and file upload

This commit is contained in:
2025-07-31 02:00:09 +09:00
parent b6ac71d598
commit d07e778570
8 changed files with 285 additions and 87 deletions
+1 -1
View File
@@ -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"
+59 -13
View File
@@ -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">
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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`}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+77 -69
View File
@@ -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>
); );
} }
+144
View File
@@ -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;