astro_avtourist/frontend/src/components/reviews/ReviewForm.tsx

689 lines
No EOL
20 KiB
TypeScript
Raw 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 { createSignal, Show, For, createEffect } from "solid-js";
interface ReviewFormProps {
onSubmit: (data: {
name: string;
surname: string;
profession: string;
rating: number;
text: string;
}) => void;
onCancel?: () => void;
user?: {
name: string;
email: string;
avatar?: string;
};
}
const DANGEROUS_PATTERNS = [
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
/javascript:/gi,
/on\w+\s*=/gi,
/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi,
/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi,
/<embed\b[^<]*>/gi,
/data:text\/html/gi,
/expression\s*\(/gi,
/url\s*\(\s*['"]*\s*javascript:/gi,
];
const MAX_TEXT_LENGTH = 500;
const MIN_TEXT_LENGTH = 50;
const MAX_NAME_LENGTH = 50;
const MAX_PROFESSION_LENGTH = 100;
interface ValidationErrors {
name?: string;
surname?: string;
profession?: string;
rating?: string;
text?: string;
}
const RATING_LABELS: Record<number, string> = {
1: "Плохо",
2: "Не очень",
3: "Нормально",
4: "Хорошо",
5: "Отлично",
};
export default function ReviewForm(props: ReviewFormProps) {
const [name, setName] = createSignal("");
const [surname, setSurname] = createSignal("");
const [profession, setProfession] = createSignal("");
const [rating, setRating] = createSignal(0);
const [hoverRating, setHoverRating] = createSignal(0);
const [text, setText] = createSignal("");
const [errors, setErrors] = createSignal<ValidationErrors>({});
const [touched, setTouched] = createSignal<{ [key: string]: boolean }>({});
const [consent, setConsent] = createSignal(false);
createEffect(() => {
if (props.user?.name) {
const parts = props.user.name.split(" ");
if (parts.length >= 2) {
setName(parts[0]);
setSurname(parts.slice(1).join(" "));
} else {
setName(props.user.name);
}
}
});
const sanitizeInput = (input: string): string => {
return input
.replace(/[<>]/g, "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/&/g, "&amp;");
};
const containsDangerousContent = (input: string): boolean => {
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(input));
};
const validateName = (value: string): string | undefined => {
const trimmed = value.trim();
if (!trimmed) return "Введите имя";
if (trimmed.length > MAX_NAME_LENGTH) return `Макс. ${MAX_NAME_LENGTH} символов`;
return undefined;
};
const validateSurname = (value: string): string | undefined => {
const trimmed = value.trim();
if (!trimmed) return "Введите фамилию";
if (trimmed.length > MAX_NAME_LENGTH) return `Макс. ${MAX_NAME_LENGTH} символов`;
return undefined;
};
const validateProfession = (value: string): string | undefined => {
const trimmed = value.trim();
if (!trimmed) return "Укажите профессию";
if (trimmed.length > MAX_PROFESSION_LENGTH) return `Макс. ${MAX_PROFESSION_LENGTH} символов`;
return undefined;
};
const validateRating = (value: number): string | undefined => {
if (!value || value < 1 || value > 5) return "Поставьте оценку";
return undefined;
};
const validateText = (value: string): string | undefined => {
const trimmed = value.trim();
if (!trimmed) return "Напишите отзыв";
if (trimmed.length < MIN_TEXT_LENGTH)
return `Минимум ${MIN_TEXT_LENGTH} символов`;
if (trimmed.length > MAX_TEXT_LENGTH)
return `Макс. ${MAX_TEXT_LENGTH} символов`;
if (containsDangerousContent(trimmed)) return "Недопустимый контент";
return undefined;
};
const handleTextChange = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value = target.value;
if (containsDangerousContent(value)) {
DANGEROUS_PATTERNS.forEach((pattern) => {
value = value.replace(pattern, "");
});
}
if (value.length > MAX_TEXT_LENGTH) {
value = value.slice(0, MAX_TEXT_LENGTH);
}
setText(value);
if (touched().text) {
setErrors((prev) => ({ ...prev, text: validateText(value) }));
}
};
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {
name: validateName(name()),
surname: validateSurname(surname()),
profession: validateProfession(profession()),
rating: validateRating(rating()),
text: validateText(text()),
};
setErrors(newErrors);
setTouched({
name: true,
surname: true,
profession: true,
rating: true,
text: true,
});
return !Object.values(newErrors).some((error) => error);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!validateForm()) return;
props.onSubmit({
name: sanitizeInput(name().trim()),
surname: sanitizeInput(surname().trim()),
profession: sanitizeInput(profession().trim()),
rating: rating(),
text: sanitizeInput(text().trim()),
});
setName("");
setSurname("");
setProfession("");
setRating(0);
setText("");
setErrors({});
setTouched({});
};
const handleBlur = (field: string) => {
setTouched((prev) => ({ ...prev, [field]: true }));
const fieldValidators: Record<string, () => string | undefined> = {
name: () => validateName(name()),
surname: () => validateSurname(surname()),
profession: () => validateProfession(profession()),
rating: () => validateRating(rating()),
text: () => validateText(text()),
};
setErrors((prev) => ({
...prev,
[field]: fieldValidators[field](),
}));
};
const isValid = () => {
return !errors().name && !errors().surname && !errors().profession &&
!errors().rating && !errors().text &&
name().trim() && surname().trim() && profession().trim() &&
rating() > 0 && text().trim() && consent();
};
const getFieldClass = (field: keyof ValidationErrors) => {
const hasError = errors()[field] && touched()[field];
return `w-full px-4 py-3 rounded-xl border transition-all resize-none bg-white text-gray-900 placeholder-gray-400 outline-none ${
hasError
? "border-red-300 focus:border-red-500 focus:ring-2 focus:ring-red-200"
: "border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
}`;
};
const displayRating = () => hoverRating() || rating();
return (
<div class="review-form-wrapper">
<div class="review-form-card">
<div class="review-form-header">
<h3 class="review-form-title">Оставить отзыв</h3>
<p class="review-form-subtitle">Ваш опыт поможет другим клиентам</p>
</div>
<form onSubmit={handleSubmit} class="review-form">
<div class="form-section">
<div class="name-row">
<div class="field-group">
<label class="field-label">Имя</label>
<input
type="text"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
onBlur={() => handleBlur("name")}
placeholder="Иван"
class={getFieldClass("name")}
/>
<Show when={errors().name && touched().name}>
<p class="field-error">{errors().name}</p>
</Show>
</div>
<div class="field-group">
<label class="field-label">Фамилия</label>
<input
type="text"
value={surname()}
onInput={(e) => setSurname(e.currentTarget.value)}
onBlur={() => handleBlur("surname")}
placeholder="Иванов"
class={getFieldClass("surname")}
/>
<Show when={errors().surname && touched().surname}>
<p class="field-error">{errors().surname}</p>
</Show>
</div>
</div>
</div>
<div class="form-section">
<div class="field-group">
<label class="field-label">Профессия</label>
<input
type="text"
value={profession()}
onInput={(e) => setProfession(e.currentTarget.value)}
onBlur={() => handleBlur("profession")}
placeholder="Например: Водитель, Предприниматель..."
class={getFieldClass("profession")}
/>
<Show when={errors().profession && touched().profession}>
<p class="field-error">{errors().profession}</p>
</Show>
</div>
</div>
<div class="form-section">
<div class="field-group">
<label class="field-label">Ваша оценка</label>
<div class="rating-stars-wrapper">
<div class="rating-stars">
<For each={[1, 2, 3, 4, 5]}>
{(star) => (
<button
type="button"
class={`star-btn ${star <= displayRating() ? "active" : ""}`}
onClick={() => {
setRating(star);
if (touched().rating) {
setErrors((prev) => ({ ...prev, rating: validateRating(star) }));
}
setTouched((prev) => ({ ...prev, rating: true }));
}}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
aria-label={`Оценка ${star}`}
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</button>
)}
</For>
</div>
<Show when={rating() > 0}>
<span class="rating-label">{RATING_LABELS[rating()]}</span>
</Show>
</div>
<Show when={errors().rating && touched().rating}>
<p class="field-error">{errors().rating}</p>
</Show>
</div>
</div>
<div class="form-section">
<div class="field-group">
<label class="field-label">Ваш отзыв</label>
<div class="review-textarea-wrapper">
<textarea
value={text()}
onInput={handleTextChange}
onBlur={() => handleBlur("text")}
placeholder="Расскажите о вашем опыте работы с нами..."
rows={5}
class={getFieldClass("text")}
/>
<div class="textarea-progress">
<div
class="progress-bar"
style={{ width: `${Math.min(100, (text().length / MIN_TEXT_LENGTH) * 100)}%` }}
/>
</div>
</div>
<div class="textarea-footer">
<div class="textarea-info">
<Show when={text().length > 0 && text().length < MIN_TEXT_LENGTH}>
<p class="min-chars-hint">
Ещё {MIN_TEXT_LENGTH - text().length} символов до отправки
</p>
</Show>
<Show when={text().length >= MIN_TEXT_LENGTH}>
<p class="text-ready-hint">Готово к отправке</p>
</Show>
</div>
<p
class={`char-count ${text().length > MAX_TEXT_LENGTH * 0.9 ? "warning" : ""}`}
>
{text().length}/{MAX_TEXT_LENGTH}
</p>
</div>
</div>
</div>
<div class="consent-section">
<label class="consent-label">
<input
type="checkbox"
checked={consent()}
onChange={(e) => setConsent(e.currentTarget.checked)}
class="consent-checkbox"
/>
<span class="consent-text">
Я согласен на <a href="/privacy">обработку персональных данных</a> и принимаю <a href="/terms">условия использования</a>
</span>
</label>
</div>
<button
type="submit"
disabled={!isValid()}
class="submit-btn"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Отправить отзыв
</button>
<p class="privacy-note">
Нажимая кнопку, вы соглашаетесь с{" "}
<a href="/privacy" class="privacy-link">политикой конфиденциальности</a>
</p>
</form>
</div>
<style>{`
.review-form-wrapper {
display: flex;
justify-content: center;
padding: 2rem 1rem;
}
.review-form-card {
width: 100%;
max-width: 520px;
background: #ffffff;
border-radius: 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.review-form-header {
padding: 1.75rem 2rem;
background: linear-gradient(135deg, #1e3050 0%, #2d4a6f 100%);
text-align: center;
}
.review-form-title {
color: #ffffff;
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
}
.review-form-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
margin: 0;
}
.review-form {
padding: 1.5rem 2rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-section {
display: flex;
flex-direction: column;
}
.name-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.name-row {
grid-template-columns: 1fr;
}
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 600;
color: #1e3050;
}
.field-error {
font-size: 0.8rem;
color: #ef4444;
margin: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.rating-stars-wrapper {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
}
.rating-stars {
display: flex;
gap: 0.35rem;
}
.star-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: #e2e8f0;
transition: all 0.2s ease;
transform-origin: center;
}
.star-btn:hover,
.star-btn.active {
color: #eac26e;
}
.star-btn:hover {
transform: scale(1.15);
}
.star-btn svg {
width: 28px;
height: 28px;
}
.rating-label {
font-size: 0.9rem;
font-weight: 600;
color: #1e3050;
}
.char-count {
font-size: 0.75rem;
color: #94a3b8;
margin: 0;
}
.char-count.warning {
color: #f59e0b;
}
.review-textarea-wrapper {
position: relative;
}
.review-textarea-wrapper textarea {
padding-bottom: 6px;
}
.textarea-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: #e2e8f0;
border-radius: 0 0 12px 12px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #eac26e 0%, #ce9f40 100%);
transition: width 0.3s ease;
border-radius: 0 0 12px 12px;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.35rem;
min-height: 1.2rem;
}
.textarea-info {
display: flex;
align-items: center;
}
.min-chars-hint {
font-size: 0.75rem;
color: #94a3b8;
margin: 0;
}
.text-ready-hint {
font-size: 0.75rem;
color: #22c55e;
margin: 0;
font-weight: 600;
}
.moderation-notice {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 8px;
margin-top: 0.5rem;
border: 1px solid #e2e8f0;
}
.moderation-notice svg {
width: 14px;
height: 14px;
color: #64748b;
flex-shrink: 0;
}
.moderation-notice span {
font-size: 0.75rem;
color: #64748b;
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #d4af37 0%, #eac26e 100%);
border: none;
border-radius: 0.75rem;
color: #1e293b;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3);
margin-top: 0.5rem;
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.consent-section {
padding: 0.75rem 0;
}
.consent-label {
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
}
.consent-checkbox {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: #d4af37;
cursor: pointer;
flex-shrink: 0;
}
.consent-text {
font-size: 0.8rem;
color: #64748b;
line-height: 1.4;
}
.consent-text a {
color: #2563eb;
text-decoration: underline;
}
.consent-text a:hover {
color: #1d4ed8;
}
.privacy-note {
font-size: 0.75rem;
color: #94a3b8;
text-align: center;
margin: 0;
}
.privacy-link {
color: #2563eb;
text-decoration: none;
}
.privacy-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.review-form {
padding: 1.25rem 1.25rem 1.5rem;
}
.review-form-header {
padding: 1.5rem 1.25rem;
}
}
`}</style>
</div>
);
}