Реалізація форми запиту функції (Feature Request) на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація форми запиту функції (Feature Request) на сайті
Середня
~2-3 робочих дні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація форми запиту функцій (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), сторінка зі списком запитів та публічне голосування — ще три-п'ять днів.