Add feedback form functionality

This commit is contained in:
kazachilo 2025-03-21 13:27:49 +03:00
parent fa5f42e285
commit 1a4d7477cb
9 changed files with 542 additions and 5 deletions

BIN
src/assets/feedback250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,6 +1,7 @@
import ahareBot from './ahare_bot250.png'; import ahareBot from './ahare_bot250.png';
import faq from './faq250.png'; import faq from './faq250.png';
import shorts from './shorts250.png'; import shorts from './shorts250.png';
import feedback from './feedback250x.png';
import balerina from './balerina250x.png'; import balerina from './balerina250x.png';
import emotions from './emotions_promo250x.png'; import emotions from './emotions_promo250x.png';
import realism from './realism_promo250x.png'; import realism from './realism_promo250x.png';
@ -38,6 +39,7 @@ export const images = {
ahareBot, ahareBot,
faq, faq,
shorts, shorts,
feedback,
balerina, balerina,
emotions, emotions,
realism, realism,

View File

@ -0,0 +1,58 @@
import React, { useState, forwardRef, useImperativeHandle } from 'react';
import FeedbackModal from './FeedbackModal';
import { sendFeedback } from '../../services/feedbackService';
import { FeedbackData } from './FeedbackModal';
// Интерфейс для ref
export interface FeedbackHandlerRef {
openFeedbackModal: () => void;
}
interface FeedbackHandlerProps {
onFeedbackSent?: () => void; // Callback после успешной отправки
}
const FeedbackHandler = forwardRef<FeedbackHandlerRef, FeedbackHandlerProps>((props, ref) => {
const { onFeedbackSent } = props;
// Состояние для модального окна обратной связи
const [isFeedbackModalVisible, setIsFeedbackModalVisible] = useState(false);
// Экспортируем метод openFeedbackModal через ref
useImperativeHandle(ref, () => ({
openFeedbackModal: () => {
setIsFeedbackModalVisible(true);
}
}));
// Обработчик отправки формы обратной связи
const handleFeedbackSubmit = async (data: FeedbackData): Promise<boolean> => {
try {
const result = await sendFeedback(data);
console.log('Результат отправки обратной связи:', result);
// Вызываем callback после успешной отправки
if (result && onFeedbackSent) {
onFeedbackSent();
}
return result;
} catch (error) {
console.error('Ошибка при отправке обратной связи:', error);
return false;
}
};
return (
<>
{/* Модальное окно обратной связи */}
<FeedbackModal
isVisible={isFeedbackModalVisible}
onClose={() => setIsFeedbackModalVisible(false)}
onSubmit={handleFeedbackSubmit}
/>
</>
);
});
export default FeedbackHandler;

View File

@ -0,0 +1,114 @@
.textarea {
width: calc(100% - var(--spacing-small) * 2);
min-height: 100px;
margin-bottom: var(--spacing-medium);
padding: var(--spacing-small);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
resize: none;
font-family: inherit;
font-size: 14px;
box-sizing: border-box;
}
.textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.imageUploadContainer {
margin-bottom: var(--spacing-medium);
width: 100%;
box-sizing: border-box;
}
.addImageButton {
padding: var(--spacing-small) var(--spacing-medium);
background-color: var(--color-border);
border: none;
border-radius: var(--border-radius);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
width: auto;
max-width: 100%;
box-sizing: border-box;
}
.addImageButton:hover {
background-color: #d0d0d0;
}
.fileInput {
display: none;
}
.imagePreviewContainer {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-small);
margin-top: var(--spacing-small);
width: 100%;
box-sizing: border-box;
}
.imagePreview {
position: relative;
width: 80px;
height: 80px;
border-radius: var(--border-radius);
overflow: hidden;
}
.imagePreview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.removeImageButton {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
cursor: pointer;
}
.errorMessage {
color: #e53935;
font-size: 14px;
margin-bottom: var(--spacing-medium);
padding: 8px;
background-color: rgba(229, 57, 53, 0.1);
border-radius: var(--border-radius);
text-align: center;
}
/* Специальные стили для iOS */
@supports (-webkit-touch-callout: none) {
.textarea {
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
}
/* Исправление для предотвращения проблем с прокруткой на iOS */
.imagePreviewContainer {
-webkit-overflow-scrolling: touch;
}
/* Дополнительные исправления для iOS */
.modal {
width: calc(100% - var(--spacing-large) * 2);
margin: 0 var(--spacing-large);
max-width: 90vw;
}
}

View File

@ -0,0 +1,190 @@
import React, { useState, useRef } from 'react';
import styles from './NotificationModal.module.css'; // Используем существующие стили
import feedbackStyles from './FeedbackModal.module.css'; // Дополнительные стили для формы
export interface FeedbackData {
text: string;
images: File[];
}
interface FeedbackModalProps {
isVisible: boolean;
onClose: () => void;
onSubmit: (data: FeedbackData) => Promise<boolean> | boolean;
}
const FeedbackModal: React.FC<FeedbackModalProps> = ({ isVisible, onClose, onSubmit }) => {
const [text, setText] = useState('');
const [images, setImages] = useState<File[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Сброс формы при закрытии
const handleClose = () => {
setText('');
setImages([]);
setError(null);
onClose();
};
// Функция для сворачивания клавиатуры
const dismissKeyboard = () => {
if (textareaRef.current) {
textareaRef.current.blur();
}
};
// Обработчик нажатия клавиш
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Если нажата клавиша Enter (или Done на iOS)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); // Предотвращаем добавление новой строки
dismissKeyboard(); // Сворачиваем клавиатуру
}
};
// Обработчик добавления изображений
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const newFiles = Array.from(e.target.files);
setImages(prevImages => [...prevImages, ...newFiles]);
// Сбрасываем значение input, чтобы можно было выбрать тот же файл повторно
e.target.value = '';
}
};
// Обработчик удаления изображения
const handleRemoveImage = (index: number) => {
setImages(prevImages => prevImages.filter((_, i) => i !== index));
};
// Обработчик отправки формы
const handleSubmit = async () => {
// Проверяем, что есть текст или изображения
if (!text.trim() && images.length === 0) {
setError('Пожалуйста, введите текст или добавьте изображение');
return;
}
try {
setIsLoading(true);
setError(null);
const result = await onSubmit({
text: text.trim(),
images
});
if (result) {
handleClose();
} else {
setError('Не удалось отправить обратную связь. Пожалуйста, попробуйте позже.');
}
} catch (err) {
console.error('Ошибка при отправке обратной связи:', err);
setError('Произошла ошибка при отправке. Пожалуйста, попробуйте позже.');
} finally {
setIsLoading(false);
}
};
if (!isVisible) return null;
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<div className={styles.title}>
{isLoading && <span className={styles.spinner}></span>}
Обратная связь
</div>
<div className={styles.message}>Расскажите нам о проблеме или предложении</div>
</div>
{/* Поле для ввода текста */}
<textarea
ref={textareaRef}
className={feedbackStyles.textarea}
placeholder="Опишите вашу проблему или предложение..."
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
enterKeyHint="done"
disabled={isLoading}
/>
{/* Блок для загрузки изображений */}
<div className={feedbackStyles.imageUploadContainer}>
<button
className={feedbackStyles.addImageButton}
onClick={() => fileInputRef.current?.click()}
type="button"
disabled={isLoading}
>
Добавить изображение
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className={feedbackStyles.fileInput}
multiple
disabled={isLoading}
/>
{/* Превью загруженных изображений */}
{images.length > 0 && (
<div className={feedbackStyles.imagePreviewContainer}>
{images.map((image, index) => (
<div key={index} className={feedbackStyles.imagePreview}>
<img src={URL.createObjectURL(image)} alt={`Preview ${index}`} />
<button
className={feedbackStyles.removeImageButton}
onClick={() => handleRemoveImage(index)}
type="button"
disabled={isLoading}
>
</button>
</div>
))}
</div>
)}
</div>
{/* Сообщение об ошибке */}
{error && (
<div className={feedbackStyles.errorMessage}>
{error}
</div>
)}
{/* Кнопки действий */}
<div className={styles.buttons}>
<button
className={`${styles.button} ${styles.secondaryButton}`}
onClick={handleClose}
type="button"
disabled={isLoading}
>
Отмена
</button>
<button
className={`${styles.button} ${styles.primaryButton}`}
onClick={handleSubmit}
type="button"
disabled={isLoading}
>
{isLoading ? 'Отправка...' : 'Отправить'}
</button>
</div>
</div>
</div>
);
};
export default FeedbackModal;

View File

@ -9,17 +9,17 @@ export const homeScreenConfig: AppConfig = {
id: 'mainActions', id: 'mainActions',
buttons: [ buttons: [
{ {
id: 'invite', id: 'feedback',
type: 'square', type: 'square',
background: { background: {
type: 'gradient', type: 'gradient',
colors: ['#FF69B4', '#FF1493'] colors: ['#FF69B4', '#FF1493']
}, },
title: 'Поделиться', title: 'Обратная связь',
imageUrl: images.ahareBot, imageUrl: images.feedback,
action: { action: {
type: 'function', type: 'function',
value: 'inviteFriends' value: 'sendFeedback'
} }
}, },
{ {

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import BlockRenderer from '../components/blocks/BlockRenderer'; import BlockRenderer from '../components/blocks/BlockRenderer';
// import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется // import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется
@ -7,6 +7,7 @@ import { homeScreenConfig } from '../config/homeScreen';
import { stylePresets } from '../config/stylePresets'; import { stylePresets } from '../config/stylePresets';
import apiService from '../services/api'; import apiService from '../services/api';
import NotificationModal from '../components/shared/NotificationModal'; import NotificationModal from '../components/shared/NotificationModal';
import FeedbackHandler, { FeedbackHandlerRef } from '../components/shared/FeedbackHandler';
// Интерфейс для хранения данных о последней генерации // Интерфейс для хранения данных о последней генерации
interface LastGenerationData { interface LastGenerationData {
@ -18,6 +19,7 @@ interface LastGenerationData {
const Home: React.FC = () => { const Home: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const feedbackHandlerRef = useRef<FeedbackHandlerRef>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [previewUrl, setPreviewUrl] = useState<string | undefined>(() => { const [previewUrl, setPreviewUrl] = useState<string | undefined>(() => {
@ -223,6 +225,12 @@ const Home: React.FC = () => {
} }
return; return;
} }
if (actionValue === 'sendFeedback') {
// Открываем модальное окно обратной связи
feedbackHandlerRef.current?.openFeedbackModal();
return;
}
} else if (actionType === 'route') { } else if (actionType === 'route') {
// Добавляем обработку для действий типа 'route' // Добавляем обработку для действий типа 'route'
navigate(actionValue); navigate(actionValue);
@ -293,6 +301,18 @@ const Home: React.FC = () => {
onContinueClick={handleContinueClick} onContinueClick={handleContinueClick}
/> />
{/* Компонент обработки обратной связи */}
<FeedbackHandler
ref={feedbackHandlerRef}
onFeedbackSent={() => {
// Показываем уведомление об успешной отправке
setNotificationTitle('Спасибо за обратную связь');
setNotificationMessage('Ваше сообщение успешно отправлено');
setIsLoading(false);
setIsNotificationVisible(true);
}}
/>
<div className={styles.content}> <div className={styles.content}>
{/* Блоки из конфигурации */} {/* Блоки из конфигурации */}
<div className={styles.blocks}> <div className={styles.blocks}>

View File

@ -0,0 +1,75 @@
import { FeedbackData } from '../components/shared/FeedbackModal';
// Функция для преобразования File в base64
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
// Получаем base64 строку и удаляем префикс (data:image/jpeg;base64,)
const base64String = reader.result as string;
const base64Content = base64String.split(',')[1];
resolve(base64Content);
};
reader.onerror = error => reject(error);
});
};
// Функция для получения информации о пользователе из Telegram
const getUserInfo = () => {
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe) {
const { user } = window.Telegram.WebApp.initDataUnsafe;
if (user) {
return {
user_id: user.id.toString(),
username: user.username || `${user.first_name} ${user.last_name || ''}`.trim()
};
}
}
// Заглушка для тестирования
return {
user_id: "test_user_id",
username: "test_user"
};
};
// Функция для отправки данных обратной связи
export const sendFeedback = async (data: FeedbackData): Promise<boolean> => {
try {
// Получаем информацию о пользователе
const userInfo = getUserInfo();
// Преобразуем все изображения в base64
const base64Images = await Promise.all(data.images.map(fileToBase64));
// Формируем данные для отправки
const requestData = {
text: data.text,
user_id: userInfo.user_id,
username: userInfo.username,
images: base64Images
};
console.log('Отправка данных обратной связи:', requestData);
// Отправляем запрос
const response = await fetch('https://feedbacksticker.gymnasticstuff.uk/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
console.log('Обратная связь успешно отправлена');
return true;
} catch (error) {
console.error('Ошибка при отправке обратной связи:', error);
return false;
}
};

78
src/types/telegram-webapp.d.ts vendored Normal file
View File

@ -0,0 +1,78 @@
interface TelegramWebAppUser {
id: number;
is_bot?: boolean;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
photo_url?: string;
}
interface TelegramWebAppInitData {
query_id?: string;
user?: TelegramWebAppUser;
auth_date?: string;
hash?: string;
}
interface TelegramWebApp {
initData: string;
initDataUnsafe: TelegramWebAppInitData;
version: string;
colorScheme: 'light' | 'dark';
themeParams: {
bg_color: string;
text_color: string;
hint_color: string;
link_color: string;
button_color: string;
button_text_color: string;
};
isExpanded: boolean;
viewportHeight: number;
viewportStableHeight: number;
headerColor: string;
backgroundColor: string;
ready(): void;
expand(): void;
close(): void;
openLink(url: string): void;
openTelegramLink(url: string): void;
showAlert(message: string, callback?: () => void): void;
showConfirm(message: string, callback?: (confirmed: boolean) => void): void;
MainButton: {
text: string;
color: string;
textColor: string;
isVisible: boolean;
isActive: boolean;
isProgressVisible: boolean;
setText(text: string): void;
onClick(callback: () => void): void;
offClick(callback: () => void): void;
show(): void;
hide(): void;
enable(): void;
disable(): void;
showProgress(leaveActive: boolean): void;
hideProgress(): void;
};
BackButton: {
isVisible: boolean;
onClick(callback: () => void): void;
offClick(callback: () => void): void;
show(): void;
hide(): void;
};
HapticFeedback: {
impactOccurred(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'): void;
notificationOccurred(type: 'error' | 'success' | 'warning'): void;
selectionChanged(): void;
};
}
interface Window {
Telegram?: {
WebApp: TelegramWebApp;
};
}