diff --git a/backend/pb_migrations/1776528654_created_reviews.js b/backend/pb_migrations/1776528654_created_reviews.js new file mode 100644 index 0000000..83036ea --- /dev/null +++ b/backend/pb_migrations/1776528654_created_reviews.js @@ -0,0 +1,70 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation2375276105", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_4163081445", + "indexes": [], + "listRule": null, + "name": "reviews", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445"); + + return app.delete(collection); +}) diff --git a/backend/pb_migrations/1776528667_updated_reviews.js b/backend/pb_migrations/1776528667_updated_reviews.js new file mode 100644 index 0000000..62b5912 --- /dev/null +++ b/backend/pb_migrations/1776528667_updated_reviews.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(2, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("text1579384326") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776528682_updated_reviews.js b/backend/pb_migrations/1776528682_updated_reviews.js new file mode 100644 index 0000000..48fcc99 --- /dev/null +++ b/backend/pb_migrations/1776528682_updated_reviews.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(3, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3883309839", + "max": 0, + "min": 0, + "name": "surname", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("text3883309839") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776528713_updated_reviews.js b/backend/pb_migrations/1776528713_updated_reviews.js new file mode 100644 index 0000000..f4d6d28 --- /dev/null +++ b/backend/pb_migrations/1776528713_updated_reviews.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(4, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text999008199", + "max": 0, + "min": 0, + "name": "text", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("text999008199") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776528727_updated_reviews.js b/backend/pb_migrations/1776528727_updated_reviews.js new file mode 100644 index 0000000..264a4ab --- /dev/null +++ b/backend/pb_migrations/1776528727_updated_reviews.js @@ -0,0 +1,27 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(5, new Field({ + "hidden": false, + "id": "number3632866850", + "max": null, + "min": null, + "name": "rating", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("number3632866850") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776528770_updated_reviews.js b/backend/pb_migrations/1776528770_updated_reviews.js new file mode 100644 index 0000000..b071f9f --- /dev/null +++ b/backend/pb_migrations/1776528770_updated_reviews.js @@ -0,0 +1,30 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(6, new Field({ + "hidden": false, + "id": "select2063623452", + "maxSelect": 1, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "pending", + "published", + "spam" + ] + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("select2063623452") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776528788_updated_reviews.js b/backend/pb_migrations/1776528788_updated_reviews.js new file mode 100644 index 0000000..8631f27 --- /dev/null +++ b/backend/pb_migrations/1776528788_updated_reviews.js @@ -0,0 +1,27 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(7, new Field({ + "hidden": false, + "id": "number1696582880", + "max": null, + "min": null, + "name": "votesCount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("number1696582880") + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776529504_updated_reviews.js b/backend/pb_migrations/1776529504_updated_reviews.js new file mode 100644 index 0000000..a2f8135 --- /dev/null +++ b/backend/pb_migrations/1776529504_updated_reviews.js @@ -0,0 +1,26 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "createRule": "", + "listRule": "", + "updateRule": "", + "viewRule": "" + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // update collection data + unmarshal({ + "createRule": null, + "listRule": null, + "updateRule": null, + "viewRule": null + }, collection) + + return app.save(collection) +}) diff --git a/backend/pb_migrations/1776529637_updated_reviews.js b/backend/pb_migrations/1776529637_updated_reviews.js new file mode 100644 index 0000000..91962ce --- /dev/null +++ b/backend/pb_migrations/1776529637_updated_reviews.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // add field + collection.fields.addAt(8, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3130199401", + "max": 0, + "min": 0, + "name": "profession", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_4163081445") + + // remove field + collection.fields.removeById("text3130199401") + + return app.save(collection) +}) diff --git a/frontend/src/components/home/Hero.astro b/frontend/src/components/home/Hero.astro new file mode 100644 index 0000000..a54c50d --- /dev/null +++ b/frontend/src/components/home/Hero.astro @@ -0,0 +1,510 @@ +--- +import Button from '@components/base/Button.astro'; +import { Icon } from 'astro-icon/components'; + +interface HeroProps { + badgeText: string; + titleWhite: string; + titleGold: string; + description: string; + btnText?: string; + btnHref?: string; + btnSecondary?: string; + btnSecondaryHref?: string; + btnSecondaryClass?: string; + modalTarget?: string; + bgImage?: string; + minHeight?: string; + headerOffset?: string; + layout?: 'default' | 'with-image'; + sideImage?: string; + sideImageAlt?: string; + experienceBadge?: { + number: string; + text: string; + }; + icon?: string; +} + +const { + badgeText, + titleWhite, + titleGold, + description, + btnText, + btnHref = "#contact", + btnSecondary, + btnSecondaryHref = "/services", + btnSecondaryClass = "", + modalTarget, + bgImage = "", + minHeight = "100vh", + headerOffset = "80px", + layout = "default", + sideImage = "", + sideImageAlt = "", + experienceBadge, + icon +} = Astro.props as HeroProps; + +const showImage = layout === 'with-image' && sideImage; +--- + + + + +
+ + {bgImage && ( + + )} + +
+ +
+
+
+ {icon ? ( + + + + ) : ( + + )} + {badgeText} +
+ +

+ {titleWhite} +
+ {titleGold} +

+ +

{description}

+ + {btnText && ( +
+ {modalTarget ? ( + + ) : ( + + )} + {btnSecondary && ( + + )} +
+ )} +
+ + {showImage && ( +
+
+ {sideImageAlt + {experienceBadge && ( +
+ {experienceBadge.number} + {experienceBadge.text} +
+ )} +
+
+ )} +
+
+ + + + \ No newline at end of file diff --git a/frontend/src/components/layout/header/Header.astro b/frontend/src/components/layout/header/Header.astro index 973c0f9..2022431 100644 --- a/frontend/src/components/layout/header/Header.astro +++ b/frontend/src/components/layout/header/Header.astro @@ -45,10 +45,9 @@ import { COMPANY } from "@constants";
-

{name}

-

{car}

+

{profession}

@@ -143,7 +143,7 @@ const formatDate = (dateStr: string) => { margin: 0; } - .author-car { + .author-profession { color: #64748b; font-size: 0.875rem; margin: 0; diff --git a/frontend/src/components/reviews/ReviewForm.tsx b/frontend/src/components/reviews/ReviewForm.tsx new file mode 100644 index 0000000..6594e28 --- /dev/null +++ b/frontend/src/components/reviews/ReviewForm.tsx @@ -0,0 +1,397 @@ +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 EMOJIS = [ + "👍", "👎", "❤️", "😊", "😂", "🎉", "🔥", "👏", + "😢", "😮", "😡", "🙏", "⭐", "💯", "❤️‍🔥", "🤔", + "👀", "💪", "🚀", "✨" +]; + +const DANGEROUS_PATTERNS = [ + /)<[^<]*)*<\/script>/gi, + /javascript:/gi, + /on\w+\s*=/gi, + /)<[^<]*)*<\/iframe>/gi, + /)<[^<]*)*<\/object>/gi, + //gi, + /data:text\/html/gi, + /expression\s*\(/gi, + /url\s*\(\s*['"]*\s*javascript:/gi, +]; + +const MAX_TEXT_LENGTH = 2000; +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; +} + +export default function ReviewForm(props: ReviewFormProps) { + const [name, setName] = createSignal(""); + const [surname, setSurname] = createSignal(""); + const [profession, setProfession] = createSignal(""); + const [rating, setRating] = createSignal(0); + const [text, setText] = createSignal(""); + const [errors, setErrors] = createSignal({}); + const [touched, setTouched] = createSignal<{ [key: string]: boolean }>({}); + const [showEmojiPicker, setShowEmojiPicker] = 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, """) + .replace(/'/g, "'") + .replace(/&/g, "&"); + }; + + 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 addEmoji = (emoji: string) => { + setText((prev) => prev + emoji); + setShowEmojiPicker(false); + }; + + 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 | 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(); + }; + + const ratingOptions = [ + { value: 5, label: "5 — Отлично" }, + { value: 4, label: "4 — Хорошо" }, + { value: 3, label: "3 — Удовлетворительно" }, + { value: 2, label: "2 — Плохо" }, + { value: 1, label: "1 — Очень плохо" }, + ]; + + const getInputClass = (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" + }`; + }; + + return ( +
+
+
+ + setName(e.currentTarget.value)} + onBlur={() => handleBlur("name")} + placeholder="Иван" + class={getInputClass("name")} + /> + +

+ + + + {errors().name} +

+
+
+ +
+ + setSurname(e.currentTarget.value)} + onBlur={() => handleBlur("surname")} + placeholder="Иванов" + class={getInputClass("surname")} + /> + +

+ + + + {errors().surname} +

+
+
+
+ +
+ + setProfession(e.currentTarget.value)} + onBlur={() => handleBlur("profession")} + placeholder="Например: Предприниматель, Врач, Инженер..." + class={getInputClass("profession")} + /> + +

+ + + + {errors().profession} +

+
+
+ +
+ + + +

+ + + + {errors().rating} +

+
+
+ +
+
+ +
+ + +
+
+ + {(emoji) => ( + + )} + +
+
+
+
+
+ -
- - - -

- Нажимая кнопку, вы соглашаетесь с - политикой конфиденциальности -

-
- ) : ( - - )} + @@ -311,20 +206,6 @@ const paginatedReviews = reviewsData.slice(startIndex, endIndex); }); }; - // Обработка формы отзыва - const setupReviewForm = () => { - const form = document.getElementById('review-form') as HTMLFormElement; - if (form) { - form.addEventListener('submit', (e) => { - e.preventDefault(); - const formData = new FormData(form); - console.log('Отправка отзыва:', Object.fromEntries(formData)); - alert('Спасибо! Ваш отзыв отправлен на модерацию.'); - form.reset(); - }); - } - }; - // Запуск setupAnimations(); setupReviewForm();