Реалізація форми запиту функцій (Feature Request) на веб-сайті
Форма Feature Request — це не просто «поле для тексту». Добре реалізована форма структурує запити користувачів: розділяє опис проблеми від пропонованого рішення, збирає контекст (хто просить, як часто стикається з проблемою) та дозволяє команді приоритизувати бекліг без телефонних дзвінків.
Структура форми
Мінімальний набір полів, який дає корисний сигнал:
- Назва запиту (коротко, суть)
- Опис проблеми (яке завдання ви намагаєтеся вирішити, не «добавте кнопку», а навіщо)
- Пропоноване рішення (опціонально)
- Категорія / область продукту
- Оцінка важливості (як часто ви стикаєтеся з проблемою)
// FeatureRequestForm.tsx (React + React Hook Form + Zod)
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
title: z
.string()
.min(10, 'Мінімум 10 символів')
.max(120, 'Максимум 120 символів'),
problem: z
.string()
.min(30, 'Опишіть проблему більш докладно')
.max(2000),
solution: z.string().max(2000).optional(),
category: z.enum(['ui-ux', 'performance', 'integrations', 'api', 'other']),
importance: z.enum(['critical', 'high', 'medium', 'low']),
email: z.string().email().optional().or(z.literal('')),
});
type FormData = z.infer<typeof schema>;
const CATEGORIES = [
{ value: 'ui-ux', label: 'Інтерфейс / UX' },
{ value: 'performance', label: 'Продуктивність' },
{ value: 'integrations', label: 'Інтеграції' },
{ value: 'api', label: 'API / розробникам' },
{ value: 'other', label: 'Інше' },
] as const;
const IMPORTANCE = [
{ value: 'critical', label: 'Критично — не можу працювати без цього' },
{ value: 'high', label: 'Висока — стикаюся щодня' },
{ value: 'medium', label: 'Середня — неручно, але терпимо' },
{ value: 'low', label: 'Низька — було б приємно мати' },
] as const;
export function FeatureRequestForm() {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
reset,
watch,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
category: 'other',
importance: 'medium',
},
});
const titleValue = watch('title', '');
const onSubmit = async (data: FormData) => {
const res = await fetch('/api/feature-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...data,
submittedAt: new Date().toISOString(),
pageUrl: window.location.href,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? 'Помилка при відправленні');
}
};
if (isSubmitSuccessful) {
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<p className="text-lg font-semibold text-green-800">Запит відправлено</p>
<p className="mt-2 text-sm text-green-700">
Ми розглянемо його при плануванні наступного релізу.
</p>
<button
onClick={() => reset()}
className="mt-4 text-sm text-green-700 underline"
>
Відправити ще один
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 max-w-xl">
{/* Заголовок */}
<div>
<label className="block text-sm font-medium mb-1">
Коротко опишіть запит
<span className="text-gray-400 ml-1 font-normal">
({titleValue.length}/120)
</span>
</label>
<input
{...register('title')}
type="text"
placeholder="Приклад: Експорт даних у CSV"
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.title && (
<p className="mt-1 text-xs text-red-600">{errors.title.message}</p>
)}
</div>
{/* Опис проблеми */}
<div>
<label className="block text-sm font-medium mb-1">
Яку проблему це вирішує?
</label>
<p className="text-xs text-gray-500 mb-1">
Опишіть ситуацію, не конкретне рішення — це допоможе нам знайти кращий підхід
</p>
<textarea
{...register('problem')}
rows={4}
placeholder="Коли я намагаюся робити X, мені приходиться Y, що неручно потому що..."
className="w-full border rounded-md px-3 py-2 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.problem && (
<p className="mt-1 text-xs text-red-600">{errors.problem.message}</p>
)}
</div>
{/* Пропоноване рішення */}
<div>
<label className="block text-sm font-medium mb-1">
Як б ви це реалізували? <span className="text-gray-400">(опціонально)</span>
</label>
<textarea
{...register('solution')}
rows={3}
placeholder="Додайте кнопку «Експорт» в меню таблиці, яка завантажує..."
className="w-full border rounded-md px-3 py-2 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Категорія */}
<div>
<label className="block text-sm font-medium mb-2">Область продукту</label>
<Controller
control={control}
name="category"
render={({ field }) => (
<div className="flex flex-wrap gap-2">
{CATEGORIES.map(cat => (
<button
key={cat.value}
type="button"
onClick={() => field.onChange(cat.value)}
className={`px-3 py-1.5 rounded-full text-xs border transition-colors ${
field.value === cat.value
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 hover:border-blue-400'
}`}
>
{cat.label}
</button>
))}
</div>
)}
/>
</div>
{/* Важність */}
<div>
<label className="block text-sm font-medium mb-2">Наскільки це важливо для вас?</label>
<div className="space-y-2">
{IMPORTANCE.map(item => (
<label key={item.value} className="flex items-start gap-2 cursor-pointer">
<input
{...register('importance')}
type="radio"
value={item.value}
className="mt-0.5"
/>
<span className="text-sm">{item.label}</span>
</label>
))}
</div>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium mb-1">
Email <span className="text-gray-400">(щоб повідомити вас про реалізацію)</span>
</label>
<input
{...register('email')}
type="email"
placeholder="[email protected]"
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 rounded-md text-sm transition-colors"
>
{isSubmitting ? 'Відправляю...' : 'Відправити запит'}
</button>
</form>
);
}
API endpoint
// pages/api/feature-requests.ts (Next.js) або routes/feature-requests.ts (Express)
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const { title, problem, solution, category, importance, email, pageUrl } = req.body;
// Базова валідація
if (!title || !problem || !category || !importance) {
return res.status(400).json({ message: 'Обов\'язкові поля не заповнені' });
}
const record = await db.featureRequest.create({
data: {
title,
problem,
solution: solution || null,
category,
importance,
email: email || null,
pageUrl,
status: 'new',
votes: 0,
},
});
// Повідомлення в Linear/Jira/Notion через webhook
if (process.env.LINEAR_WEBHOOK_URL) {
await fetch(process.env.LINEAR_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `[Feature] ${title}`,
description: `**Проблема:**\n${problem}\n\n**Рішення:**\n${solution ?? 'не вказано'}`,
priority: importance === 'critical' ? 1 : importance === 'high' ? 2 : 3,
labelIds: [CATEGORY_LABEL_MAP[category]],
}),
});
}
return res.status(201).json({ id: record.id });
}
Інтеграція з системою голосування
Якщо планується додати апвоти до запитів — форму варто відразу проектувати з унікальним ID записи та сторінкою /roadmap або /feature-requests, де користувачі бачать і голосують за вже існуючі запити. Це дозволяє уникнути дублювання та збирати реальні сигнали пріоритетності.
Часові рамки
Форма з валідацією, API та повідомленнями — два-три дні. Додавання дедупліації (пошук подібних запитів перед відправленням через простий text search), сторінка зі списком запитів та публічне голосування — ще три-п'ять днів.







