368 lines
16 KiB
TypeScript
Executable File
368 lines
16 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|