Add feedback form functionality
This commit is contained in:
parent
fa5f42e285
commit
1a4d7477cb
BIN
src/assets/feedback250x.png
Normal file
BIN
src/assets/feedback250x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@ -1,6 +1,7 @@
|
||||
import ahareBot from './ahare_bot250.png';
|
||||
import faq from './faq250.png';
|
||||
import shorts from './shorts250.png';
|
||||
import feedback from './feedback250x.png';
|
||||
import balerina from './balerina250x.png';
|
||||
import emotions from './emotions_promo250x.png';
|
||||
import realism from './realism_promo250x.png';
|
||||
@ -38,6 +39,7 @@ export const images = {
|
||||
ahareBot,
|
||||
faq,
|
||||
shorts,
|
||||
feedback,
|
||||
balerina,
|
||||
emotions,
|
||||
realism,
|
||||
|
||||
58
src/components/shared/FeedbackHandler.tsx
Normal file
58
src/components/shared/FeedbackHandler.tsx
Normal 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;
|
||||
114
src/components/shared/FeedbackModal.module.css
Normal file
114
src/components/shared/FeedbackModal.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
190
src/components/shared/FeedbackModal.tsx
Normal file
190
src/components/shared/FeedbackModal.tsx
Normal 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;
|
||||
@ -9,17 +9,17 @@ export const homeScreenConfig: AppConfig = {
|
||||
id: 'mainActions',
|
||||
buttons: [
|
||||
{
|
||||
id: 'invite',
|
||||
id: 'feedback',
|
||||
type: 'square',
|
||||
background: {
|
||||
type: 'gradient',
|
||||
colors: ['#FF69B4', '#FF1493']
|
||||
},
|
||||
title: 'Поделиться',
|
||||
imageUrl: images.ahareBot,
|
||||
title: 'Обратная связь',
|
||||
imageUrl: images.feedback,
|
||||
action: {
|
||||
type: 'function',
|
||||
value: 'inviteFriends'
|
||||
value: 'sendFeedback'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -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 BlockRenderer from '../components/blocks/BlockRenderer';
|
||||
// import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется
|
||||
@ -7,6 +7,7 @@ import { homeScreenConfig } from '../config/homeScreen';
|
||||
import { stylePresets } from '../config/stylePresets';
|
||||
import apiService from '../services/api';
|
||||
import NotificationModal from '../components/shared/NotificationModal';
|
||||
import FeedbackHandler, { FeedbackHandlerRef } from '../components/shared/FeedbackHandler';
|
||||
|
||||
// Интерфейс для хранения данных о последней генерации
|
||||
interface LastGenerationData {
|
||||
@ -18,6 +19,7 @@ interface LastGenerationData {
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const feedbackHandlerRef = useRef<FeedbackHandlerRef>(null);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>(() => {
|
||||
@ -223,6 +225,12 @@ const Home: React.FC = () => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionValue === 'sendFeedback') {
|
||||
// Открываем модальное окно обратной связи
|
||||
feedbackHandlerRef.current?.openFeedbackModal();
|
||||
return;
|
||||
}
|
||||
} else if (actionType === 'route') {
|
||||
// Добавляем обработку для действий типа 'route'
|
||||
navigate(actionValue);
|
||||
@ -293,6 +301,18 @@ const Home: React.FC = () => {
|
||||
onContinueClick={handleContinueClick}
|
||||
/>
|
||||
|
||||
{/* Компонент обработки обратной связи */}
|
||||
<FeedbackHandler
|
||||
ref={feedbackHandlerRef}
|
||||
onFeedbackSent={() => {
|
||||
// Показываем уведомление об успешной отправке
|
||||
setNotificationTitle('Спасибо за обратную связь');
|
||||
setNotificationMessage('Ваше сообщение успешно отправлено');
|
||||
setIsLoading(false);
|
||||
setIsNotificationVisible(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* Блоки из конфигурации */}
|
||||
<div className={styles.blocks}>
|
||||
|
||||
75
src/services/feedbackService.ts
Normal file
75
src/services/feedbackService.ts
Normal 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
78
src/types/telegram-webapp.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user