Files
mkd/components/pr/NPSSurveyPage.tsx
2026-02-04 00:17:04 +05:00

368 lines
16 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Star, MessageSquare, CheckCircle2, X, Lock } from 'lucide-react';
import { apiClient, getAuthToken, setAuthToken, fetchGuestToken } from '../../services/apiClient';
import { NPSSurvey } from '../../types';
interface NPSSurveyPageProps {
surveyId: string | number;
apartment?: string; // Номер квартиры из параметров URL
}
// Используем тип из types.ts
export const NPSSurveyPage: React.FC<NPSSurveyPageProps> = ({ surveyId, apartment: apartmentProp }) => {
const [survey, setSurvey] = useState<NPSSurvey | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAuthorized, setIsAuthorized] = useState(false);
const [accessKeyInput, setAccessKeyInput] = useState('');
const [score, setScore] = useState<number | null>(null);
const [comment, setComment] = useState('');
const [respondentName, setRespondentName] = useState('');
const [apartment, setApartment] = useState(apartmentProp || '');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
// Получаем номер квартиры из URL параметров, если не передан в пропсах
useEffect(() => {
if (!apartmentProp) {
const urlParams = new URLSearchParams(window.location.search);
const apartmentFromUrl = urlParams.get('apartment');
if (apartmentFromUrl) {
setApartment(apartmentFromUrl);
}
}
}, [apartmentProp]);
const loadSurvey = useCallback(async () => {
if (!surveyId) {
console.error('Survey ID is missing');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
// Гость без учётки: если нет токена — получаем гостевой (доступ к опросам)
if (!getAuthToken()) {
try {
const guestToken = await fetchGuestToken();
setAuthToken(guestToken);
} catch (e) {
console.error('Failed to get guest token:', e);
}
}
console.log(`Loading survey with ID: ${surveyId}`);
const data = await apiClient.get(`/pr/nps-surveys/${surveyId}`);
console.log('Survey loaded:', data);
setSurvey(data);
} catch (err: any) {
console.error('Error loading survey:', err);
const errorMessage = err.response?.data?.error || err.message || 'Неизвестная ошибка';
console.error(`Failed to load survey ${surveyId}:`, errorMessage);
// Не показываем alert сразу - покажем ошибку в UI
setSurvey(null);
} finally {
// Гарантированно устанавливаем isLoading в false
setIsLoading(false);
}
}, [surveyId]);
useEffect(() => {
if (surveyId) {
loadSurvey();
}
}, [surveyId, loadSurvey]);
useEffect(() => {
if (survey) {
// Проверяем ключ из URL
const urlParams = new URLSearchParams(window.location.search);
const keyFromUrl = urlParams.get('key');
if (keyFromUrl === survey.accessKey) {
setIsAuthorized(true);
} else if (!keyFromUrl) {
// Если ключа нет в URL, но опрос загружен - показываем форму ввода
setIsAuthorized(false);
} else {
// Неверный ключ
setIsAuthorized(false);
}
}
}, [survey]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (score === null) {
alert('Пожалуйста, выберите оценку');
return;
}
try {
setIsSubmitting(true);
await apiClient.post(`/pr/nps-surveys/${surveyId}/responses`, {
score,
comment: comment || null,
respondent_name: respondentName || null,
apartment: apartment || null,
access_key: survey?.accessKey
});
setIsSubmitted(true);
} catch (err: any) {
console.error('Error submitting response:', err);
const errorMessage = err.response?.data?.error || err.message || 'Неизвестная ошибка';
alert(`Ошибка отправки ответа: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
};
// Форма ввода ключа доступа
if (!isAuthorized && survey) {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-slate-200">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-100 rounded-full mb-4">
<Lock className="w-8 h-8 text-indigo-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-2">Доступ к опросу</h2>
<p className="text-sm text-slate-600">Введите ключ доступа для участия в опросе</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-2 uppercase tracking-wider">
Ключ доступа
</label>
<input
type="password"
value={accessKeyInput}
onChange={(e) => setAccessKeyInput(e.target.value)}
placeholder="Введите ключ доступа"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
onKeyPress={(e) => {
if (e.key === 'Enter' && survey && accessKeyInput === survey.accessKey) {
setIsAuthorized(true);
const url = new URL(window.location.href);
url.searchParams.set('key', accessKeyInput);
window.history.pushState({}, '', url.toString());
}
}}
/>
</div>
<button
onClick={() => {
if (survey && accessKeyInput === survey.accessKey) {
setIsAuthorized(true);
const url = new URL(window.location.href);
url.searchParams.set('key', accessKeyInput);
window.history.pushState({}, '', url.toString());
} else {
alert('Неверный ключ доступа');
setAccessKeyInput('');
}
}}
className="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Войти
</button>
</div>
</div>
</div>
);
}
// Страница благодарности после отправки
if (isSubmitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-white to-teal-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-slate-200 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-emerald-100 rounded-full mb-6">
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-3">Спасибо за ваш отзыв!</h2>
<p className="text-slate-600 mb-6">
Ваше мнение очень важно для нас. Мы используем ваши ответы для улучшения качества обслуживания.
</p>
<div className="pt-6 border-t border-slate-200">
<p className="text-xs text-slate-400">
Ваш ответ сохранен анонимно
</p>
</div>
</div>
</div>
);
}
// Основная страница опроса
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка опроса...</p>
</div>
</div>
);
}
// Если опрос не загружен и не загружается - показываем ошибку
if (!survey) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-pink-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-red-200 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
<X className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-3">Ошибка загрузки опроса</h2>
<p className="text-slate-600 mb-6">
Не удалось загрузить опрос. Проверьте правильность ссылки или обратитесь к администратору.
</p>
<p className="text-xs text-slate-400 mb-4">
ID опроса: {surveyId}
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Обновить страницу
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-3xl mb-6 shadow-xl">
<Building2 className="w-10 h-10 text-white" />
</div>
<h1 className="text-4xl font-black text-slate-800 mb-3">{survey.title}</h1>
{survey.description && (
<p className="text-lg text-slate-600 max-w-xl mx-auto">{survey.description}</p>
)}
<div className="mt-4 space-y-2">
{survey.address && (
<p className="text-sm text-slate-500 font-bold">📍 {survey.address}</p>
)}
{apartment && (
<p className="text-base text-indigo-600 font-black bg-indigo-50 px-4 py-2 rounded-xl inline-block">
Квартира {apartment}
</p>
)}
</div>
</div>
{/* Опрос */}
<form onSubmit={handleSubmit} className="bg-white rounded-3xl p-8 shadow-2xl border border-slate-200">
{/* Оценка от 0 до 10 */}
<div className="mb-8">
<label className="block text-lg font-black text-slate-800 mb-6 text-center">
Насколько вероятно, что вы порекомендуете нашу управляющую компанию друзьям или коллегам?
</label>
<div className="flex justify-center gap-2 flex-wrap">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<button
key={num}
type="button"
onClick={() => setScore(num)}
className={`w-14 h-14 rounded-xl font-black text-lg transition-all transform hover:scale-110 ${
score === num
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white shadow-lg scale-110'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{num}
</button>
))}
</div>
<div className="flex justify-between mt-4 text-xs text-slate-500 font-bold">
<span>Точно нет</span>
<span>Нейтрально</span>
<span>Определенно да</span>
</div>
</div>
{/* Дополнительная информация */}
<div className="space-y-4 mb-8">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Комментарий (необязательно)
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Поделитесь своими мыслями..."
rows={4}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Ваше имя (необязательно)
</label>
<input
type="text"
value={respondentName}
onChange={(e) => setRespondentName(e.target.value)}
placeholder="Имя"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{/* Номер квартиры - показываем, если уже указан, или позволяем ввести */}
{apartment ? (
<div className="p-4 bg-indigo-50 rounded-xl border border-indigo-200">
<label className="block text-sm font-bold text-indigo-700 mb-2">
Квартира
</label>
<p className="text-lg font-black text-indigo-800">{apartment}</p>
<button
type="button"
onClick={() => setApartment('')}
className="mt-2 text-xs text-indigo-600 hover:text-indigo-800 font-bold"
>
Изменить
</button>
</div>
) : (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Квартира (необязательно)
</label>
<input
type="text"
value={apartment}
onChange={(e) => setApartment(e.target.value)}
placeholder="№ квартиры"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
)}
</div>
{/* Кнопка отправки */}
<button
type="submit"
disabled={score === null || isSubmitting}
className="w-full px-6 py-4 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white rounded-xl text-base font-black uppercase tracking-wider shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-95"
>
{isSubmitting ? 'Отправка...' : 'Отправить ответ'}
</button>
<p className="text-xs text-slate-400 text-center mt-4">
Ваши ответы анонимны и используются только для улучшения качества обслуживания
</p>
</form>
</div>
</div>
);
};