Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f788ed9a | ||
|
|
5924e7842e | ||
|
|
bfaa5b3783 | ||
|
|
32f3fd5ea3 | ||
|
|
73d4354331 | ||
|
|
b13f2ccddc | ||
|
|
8437857ec1 | ||
|
|
81f7fd5b8b | ||
|
|
090b641906 | ||
|
|
a585a6a823 | ||
|
|
c2bc2bf39d | ||
|
|
e53cea0c1c | ||
|
|
8e9eeecce8 | ||
|
|
9b23e70967 | ||
|
|
51485ac83d | ||
|
|
c6abc64239 | ||
|
|
6b12d6a0cc | ||
|
|
77209f1069 | ||
|
|
b5eb4ce93f | ||
|
|
df3165dac0 | ||
|
|
78b8ff61a7 | ||
|
|
d418de1bf3 | ||
|
|
ce62c14e06 | ||
|
|
a77dcd174b | ||
|
|
ad7be5b9ab | ||
|
|
846e8ef91a | ||
|
|
ea3fd3e05e | ||
|
|
15ced43fe4 | ||
|
|
b8422f5472 | ||
|
|
0c927a57f6 | ||
|
|
50c95665c8 | ||
|
|
530f583727 | ||
|
|
b229b5b84c | ||
|
|
8889c6bee7 | ||
|
|
21936f0aef | ||
|
|
2bbfb4239a | ||
|
|
c6b9001f74 | ||
|
|
b7027d1183 | ||
|
|
3bf10b0e52 | ||
|
|
acaf65b066 | ||
|
|
9e0c44a0c9 | ||
|
|
b6f66ab4a7 | ||
|
|
797d4a091f | ||
|
|
1cb9f9a509 | ||
|
|
ba921e8348 | ||
|
|
27523cb2a1 | ||
|
|
524d9e3deb | ||
|
|
d4f9aebf00 | ||
|
|
20afce31dd | ||
|
|
e75023f15a | ||
|
|
8e7a687ab3 | ||
|
|
e7699bba77 | ||
|
|
941e3ea9df | ||
|
|
cacc69a7d3 | ||
|
|
053128ca50 | ||
|
|
b205ca9204 | ||
|
|
5de57cc7ad | ||
|
|
df7a3a542c | ||
|
|
f270c6c0fe | ||
|
|
f6b59e9a23 | ||
|
|
79167c37ca | ||
|
|
ed1c305cbb | ||
|
|
a5093b74be | ||
|
|
fd28afea41 | ||
|
|
1699873ea8 | ||
|
|
8aa8e04077 | ||
|
|
5451970f02 | ||
|
|
cee1ed8bb7 | ||
|
|
a18a706366 | ||
|
|
e142ee7998 | ||
|
|
0f36dfbded | ||
|
|
5b32ece367 | ||
|
|
5d9191ac24 | ||
|
|
676423aed5 | ||
|
|
277558b34c | ||
|
|
1f0b2ff3eb | ||
|
|
e3fc84a637 | ||
|
|
3cd1a9c1e4 | ||
|
|
0e704bc6a0 | ||
|
|
7c14baca77 | ||
|
|
267ab815b4 | ||
|
|
28e1c91961 |
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.forgejo
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.vite
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: Build, Test and Push Flask App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker.io
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
- name: Configure Docker CLI for insecure registry
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.docker
|
||||||
|
echo '{"insecure-registries" : ["git.srcoder.moe"]}' > ~/.docker/config.json
|
||||||
|
cat ~/.docker/config.json
|
||||||
|
|
||||||
|
- name: 'Docker Tags'
|
||||||
|
id: tags
|
||||||
|
uses: https://github.com/cssnr/docker-tags-action@v2
|
||||||
|
with:
|
||||||
|
images: 'git.srcoder.moe/pvhproject/pvhfrontend/'
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.srcoder.moe
|
||||||
|
username: ${{ vars.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.srcoder.moe/pvhproject/pvhfrontend/pvh:latest
|
||||||
|
git.srcoder.moe/pvhproject/pvhfrontend/pvh:${{ github.sha }}
|
||||||
|
labels: ${{ steps.tags.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: docker-build-and-push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Ansible
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y ansible openssh-client
|
||||||
|
|
||||||
|
- name: Set up SSH key
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keygen -y -f ~/.ssh/deploy_key >/dev/null
|
||||||
|
|
||||||
|
- name: Trust server host key
|
||||||
|
run: |
|
||||||
|
ssh-keyscan -H "${{ secrets.SERVER_IP }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Test SSH
|
||||||
|
run: |
|
||||||
|
ssh \
|
||||||
|
-i ~/.ssh/deploy_key \
|
||||||
|
-o StrictHostKeyChecking=yes \
|
||||||
|
"${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}" \
|
||||||
|
'echo SSH works'
|
||||||
|
|
||||||
|
- name: Run Ansible playbook
|
||||||
|
run: |
|
||||||
|
ansible-playbook \
|
||||||
|
-i "${{ secrets.SERVER_IP }}," \
|
||||||
|
ansible/restart_compose.yml \
|
||||||
|
--user "${{ secrets.SERVER_USER }}" \
|
||||||
|
--private-key ~/.ssh/deploy_key
|
||||||
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ========== СТАДИЯ 1: СБОРКА ==========
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||||
|
|
||||||
|
# Копируем зависимости
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Копируем исходники
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Сборка проекта
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
RUN node scripts/prerender.js
|
||||||
|
|
||||||
|
# ========== СТАДИЯ 2: СЕРВЕР ==========
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Копируем собранные файлы из стадии 1
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Конфиг для SPA (чтобы react-router работал)
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 80; \
|
||||||
|
location / { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
## Категории
|
||||||
|
|
||||||
|
### Получение всех категорий
|
||||||
|
**Запрос:** `GET /api/categories`
|
||||||
|
|
||||||
|
**Получаю:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "для внутренней канализации",
|
||||||
|
"image": "/images/hghfghgd.jpg",
|
||||||
|
"atributes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "цвет",
|
||||||
|
"flag": "color",
|
||||||
|
"vals": [
|
||||||
|
{ "id": 1001, "val": "оранжевый" },
|
||||||
|
{ "id": 1002, "val": "серый" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "размер",
|
||||||
|
"flag": "size",
|
||||||
|
"vals": [
|
||||||
|
{ "id": 2001, "val": "..." },
|
||||||
|
{ "id": 2002, "val": "...." },
|
||||||
|
{ "id": 2003, "val": "...." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "кол-во в упаковке",
|
||||||
|
"flag": "хз какой то флаг",
|
||||||
|
"vals": [
|
||||||
|
{ "id": 2001, "val": "2" },
|
||||||
|
{ "id": 2002, "val": "4" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "для внешней канализации",
|
||||||
|
"image": "/images/sfjsjsjf.jpg",
|
||||||
|
"atributes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "цвет",
|
||||||
|
"flag": "color",
|
||||||
|
"vals": [
|
||||||
|
{ "id": 1001, "val": "оранжевый" },
|
||||||
|
{ "id": 1002, "val": "серый" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "размер",
|
||||||
|
"flag": "size",
|
||||||
|
"vals": [
|
||||||
|
{ "id": 2001, "val": "..." },
|
||||||
|
{ "id": 2002, "val": "...." },
|
||||||
|
{ "id": 2003, "val": "...." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "кол-во в упаковке",
|
||||||
|
"flag": "хз какой то флаг",
|
||||||
|
"vals": [
|
||||||
|
{ "id": 2001, "val": "2" },
|
||||||
|
{ "id": 2002, "val": "4" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Товары (Products)
|
||||||
|
|
||||||
|
### Получение товаров по id категории с фильтрацией
|
||||||
|
**Запрос:** `GET /products`
|
||||||
|
|
||||||
|
**Параметры запроса (Query Parameters):**
|
||||||
|
|
||||||
|
| Параметр | Тип | Обязательный | Описание | Пример |
|
||||||
|
|----------|-----|--------------|----------|---------|
|
||||||
|
| `categoryId` | number | Да | ID категории | `categoryId=1` |
|
||||||
|
| `page` | number | Нет | Номер страницы (по умолчанию 1) | `page=2` |
|
||||||
|
| `limit` | number | Нет | Количество товаров на странице (по умолчанию 9) | `limit=12` |
|
||||||
|
| `filters` | object | Нет | Фильтры по атрибутам | `size=110х2,2х500&color=оранжевый` |
|
||||||
|
|
||||||
|
**Примеры запросов:**
|
||||||
|
|
||||||
|
1. **Базовый запрос:**
|
||||||
|
```
|
||||||
|
GET /products?categoryId=1&page=1&limit=9
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **С фильтрами (одиночные значения):**
|
||||||
|
```
|
||||||
|
GET /products?categoryId=1&page=1&limit=9&size=110х2,2х500&color=оранжевый
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **С фильтрами (массив значений):**
|
||||||
|
```
|
||||||
|
GET /products?categoryId=1&page=1&limit=9&size=110х2,2х500,110х2,2х1500&color=серый
|
||||||
|
```
|
||||||
|
|
||||||
|
**Получаю:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"name": "Труба ПВХ",
|
||||||
|
"description": "бля бля",
|
||||||
|
"categoryId": 1,
|
||||||
|
"images": ["/images/s23-1.jpg", "/images/s23-2.jpg"],
|
||||||
|
"attributes": {
|
||||||
|
"color": "серый",
|
||||||
|
"amount": "4",
|
||||||
|
"size": "110х2,2х500"
|
||||||
|
},
|
||||||
|
"inStock": true,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 45,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 9
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение товаров по id
|
||||||
|
**Запрос:** `GET/product/fetchById`
|
||||||
|
|
||||||
|
**Параметры запроса:**
|
||||||
|
- `id` (path parameter) - уникальный идентификатор товара
|
||||||
|
|
||||||
|
**Пример запроса:**
|
||||||
|
```
|
||||||
|
GET /api/products/101
|
||||||
|
```
|
||||||
|
|
||||||
|
**Получаю:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"name": "ПВХ труба",
|
||||||
|
"description": "бля бля",
|
||||||
|
"categoryId": 1,
|
||||||
|
"images": [
|
||||||
|
"/images/s23-1.jpg",
|
||||||
|
"/images/s23-2.jpg",
|
||||||
|
"/images/s23-3.jpg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение списка товаров
|
||||||
|
|
||||||
|
**Запрос**
|
||||||
|
```
|
||||||
|
GET api/productList/
|
||||||
|
```
|
||||||
|
|
||||||
|
**получаю**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"full_name": "Труба ПВХ для НАРУЖНОЙ канализации (цвет оранжево-коричневый), со сплошной стенкой 110x2.3x1000",
|
||||||
|
"description": "Труба ПВХ для НАРУЖНОЙ канализации (цвет оранжево-коричневый), со сплошной стенкой 110x2.3x1000 Труба ПВХ для наружная канализации, Диаметр: 110мм, Толщина стенки: 2.3мм, Длина: 1000мм Тип применения: наружная канализация Размеры: 110x2.3x1000 мм Цвет: оранжево-коричневый",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"full_name": "Труба ПВХ для НАРУЖНОЙ канализации (цвет оранжево-коричневый), со сплошной стенкой 110x2.3x1000",
|
||||||
|
"description": "Труба ПВХ для НАРУЖНОЙ канализации (цвет оранжево-коричневый), со сплошной стенкой 110x2.3x1000 Труба ПВХ для наружная канализации, Диаметр: 110мм, Толщина стенки: 2.3мм, Длина: 1000мм Тип применения: наружная канализация Размеры: 110x2.3x1000 мм Цвет: оранжево-коричневый",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
- name: Restart Docker Compose services
|
||||||
|
hosts: all
|
||||||
|
# become: yes # <-- больше не нужно, т.к. пользователь уже имеет права Docker
|
||||||
|
tasks:
|
||||||
|
- name: docker compose down
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: docker compose down pvh-frontend
|
||||||
|
chdir: /etc/compose/pvh
|
||||||
|
- name: prune system
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: docker system prune -a -f
|
||||||
|
- name: docker compose up -d
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: docker compose up -d pvh-frontend
|
||||||
|
chdir: /etc/compose/pvh
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export const API_BASE = g;/* process.env.REACT_APP_API_BASE || "";*/
|
|
||||||
const client = axios.create({
|
|
||||||
baseURL: API_BASE,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default client;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import styles from './Banner.module.css'
|
|
||||||
import React from "react";
|
|
||||||
import { Carousel } from "antd";
|
|
||||||
import Button from '../Button/Button.tsx';
|
|
||||||
|
|
||||||
export interface SlideItem {
|
|
||||||
id: number;
|
|
||||||
image: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const slides: SlideItem[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
image: "/images/banner1.jpg",
|
|
||||||
title: "ПРОИЗВОДСТВО",
|
|
||||||
subtitle: "ЛЫЖНЫХ СМАЗОК, АКСЕСУАРОВ И ПРОДУКЦИИ",
|
|
||||||
description: "ДЛЯ ЗИМНИХ ВИДОВ СПОРТА",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
image: "/images/banner2.jpg",
|
|
||||||
title: "ПРОИЗВОДСТВО",
|
|
||||||
subtitle: "ЛЫЖНЫХ СМАЗОК, АКСЕСУАРОВ И ПРОДУКЦИИ",
|
|
||||||
description: "ДЛЯ ЗИМНИХ ВИДОВ СПОРТА",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
image: "/images/banner3.jpg",
|
|
||||||
title: "ПРОИЗВОДСТВО",
|
|
||||||
subtitle: "ЛЫЖНЫХ СМАЗОК, АКСЕСУАРОВ И ПРОДУКЦИИ",
|
|
||||||
description: "ДЛЯ ЗИМНИХ ВИДОВ СПОРТА",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
const BannerSlider: React.FC = () => {
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
console.log('Кнопка нажата!');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<Carousel autoplay dots className={styles.carousel}>
|
|
||||||
{slides.map((slide) => (
|
|
||||||
<div key={slide.id}>
|
|
||||||
<div className={styles.slide}>
|
|
||||||
<div className={styles.textBlock}>
|
|
||||||
<h2>{slide.title}</h2>
|
|
||||||
<h1>
|
|
||||||
{slide.subtitle}
|
|
||||||
<br /> {slide.description}
|
|
||||||
</h1>
|
|
||||||
<Button
|
|
||||||
label="СМОТРЕТЬ КАТАЛОГ"
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<img src={slide.image} alt="banner" className={styles.image} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Carousel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default BannerSlider;
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
grid-auto-rows: 230px;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #ececec;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.highlight {
|
|
||||||
grid-column: span 2;
|
|
||||||
grid-row: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
left: 40px;
|
|
||||||
bottom: 40px;
|
|
||||||
color: white;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.overlay h3 {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.overlay h4 {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.button {
|
|
||||||
margin-top: 20px;
|
|
||||||
background: #ffd400 !important;
|
|
||||||
color: black !important;
|
|
||||||
border: none !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.titleSmall {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 15px;
|
|
||||||
left: 15px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .image {
|
|
||||||
transform: scale(1.05);
|
|
||||||
transition: transform 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: #ffd400;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.highlight:hover .overlay h3,
|
|
||||||
.highlight:hover .overlay h4 {
|
|
||||||
transform: translateX(4px);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.titleSmall {
|
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.card:hover .titleSmall {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
.highlight {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.highlight {
|
|
||||||
grid-column: span 1;
|
|
||||||
grid-row: span 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./Categories.module.css";
|
|
||||||
import Button from "../Button/Button";
|
|
||||||
|
|
||||||
interface CategoryItem {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
image: string;
|
|
||||||
highlight?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories: CategoryItem[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "СРЕДСТВА",
|
|
||||||
subtitle: "ДЛЯ УХОДА",
|
|
||||||
image: "/images/cat-care.jpg",
|
|
||||||
highlight: true,
|
|
||||||
},
|
|
||||||
{ id: 2, title: "СМАЗКИ СКОЛЬЖЕНИЯ", subtitle: "(ПАРАФИНЫ)", image: "/images/cat-paraffin.jpg" },
|
|
||||||
{ id: 3, title: "МАЗИ ДЕРЖАНИЯ", image: "/images/cat-hold.jpg" },
|
|
||||||
{ id: 4, title: "СМЫВКА", image: "/images/cat-wash.jpg" },
|
|
||||||
{ id: 5, title: "СМОЛА", image: "/images/cat-resin.jpg" },
|
|
||||||
{ id: 6, title: "СКРЕБКИ", image: "/images/cat-scraper.jpg" },
|
|
||||||
{ id: 7, title: "ПРОБКИ / РАСТИРКИ", image: "/images/cat-cork.jpg" },
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
title: "ПОЛЕЗНЫЕ",
|
|
||||||
subtitle: "АКССЕСУАРЫ",
|
|
||||||
highlight: true,
|
|
||||||
image: "/images/cat-accessories.jpg",
|
|
||||||
},
|
|
||||||
{ id: 9, title: "СВЯЗКИ И СКРЕПКИ ДЛЯ ЛЫЖ", image: "/images/cat-straps.jpg" },
|
|
||||||
{ id: 10, title: "ЧЕХЛЫ, СУМКИ", image: "/images/cat-bags.jpg" },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
const CategoriesGrid: React.FC = () => {
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
console.log('Кнопка нажата!');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<h2 className={styles.header}>КАТЕГОРИИ ТОВАРОВ</h2>
|
|
||||||
|
|
||||||
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<div
|
|
||||||
key={cat.id}
|
|
||||||
className={`${styles.card} ${cat.highlight ? styles.highlight : ""}`}
|
|
||||||
>
|
|
||||||
<img src={cat.image} className={styles.image} alt={cat.title} />
|
|
||||||
|
|
||||||
|
|
||||||
{cat.highlight && (
|
|
||||||
<div className={styles.overlay}>
|
|
||||||
<h3>{cat.title}</h3>
|
|
||||||
{cat.subtitle && <h4>{cat.subtitle}</h4>}
|
|
||||||
<Button
|
|
||||||
label="СМОТРЕТЬ КАТАЛОГ"
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{!cat.highlight && (
|
|
||||||
<div className={styles.titleSmall}>
|
|
||||||
<span>{cat.title}</span>
|
|
||||||
{cat.subtitle && <span>{cat.subtitle}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default CategoriesGrid;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
.footer {
|
|
||||||
color: #bfbfbf;
|
|
||||||
background: #000;
|
|
||||||
padding: 60px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: 90%;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 160px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.copy {
|
|
||||||
font-size: 14px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.center a {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.center a:hover {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.right {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.phone {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.mail {
|
|
||||||
margin-top: 6px;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #bfbfbf;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.mail:hover {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Mobile */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.container {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 40px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.right {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./Footer.module.css";
|
|
||||||
import { NavLink } from "react-router-dom";
|
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<footer className={styles.footer}>
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.left}>
|
|
||||||
<img src="/images/logo-footer.png" alt="logo" className={styles.logo} />
|
|
||||||
<span className={styles.copy}>ТРК Спринт. 2017-2025 ©</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className={styles.center}>
|
|
||||||
<NavLink to="about" >O КОМПАНИИ</NavLink>
|
|
||||||
<NavLink to="products" >ПРОДУКЦИЯ</NavLink>
|
|
||||||
<a>ПРАЙС-ЛИСТ</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className={styles.right}>
|
|
||||||
<div className={styles.phone}>+7 910 121 09 62</div>
|
|
||||||
<a className={styles.mail}>rkplastic@mail.ru</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { NavLink } from "react-router-dom";
|
|
||||||
import styles from './Header.module.css'
|
|
||||||
import logo from '../../assets/images/Logo.jpg'
|
|
||||||
|
|
||||||
const Header = () =>{
|
|
||||||
|
|
||||||
return(
|
|
||||||
<nav className={styles.header}>
|
|
||||||
<div className={styles.headerMenu}>
|
|
||||||
<div className={styles.headeLogo}>
|
|
||||||
<img src={logo} alt="logo" className={styles.logoImage}/>
|
|
||||||
<div className={styles.phoneBlock}>
|
|
||||||
<a href="" className={styles.phoneBlock}>+7 905 010 58 40</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.headerNav}>
|
|
||||||
<NavLink to="/" end >ГЛАВНАЯ</NavLink>
|
|
||||||
<NavLink to="about" >O КОМПАНИИ</NavLink>
|
|
||||||
<NavLink to="products" >ПРОДУКЦИЯ</NavLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
.phoneIcon {
|
|
||||||
font-size: 28px;
|
|
||||||
color: #ffd400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
width: 100%;
|
|
||||||
background-image: url("");
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
padding: 120px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inner {
|
|
||||||
width: 90%;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
max-width: 45%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 42px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
|
||||||
max-width: 50%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phoneBlock {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phoneNumber {
|
|
||||||
font-size: 25px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phoneInfo {
|
|
||||||
margin-left: 44px;
|
|
||||||
margin-top: -5px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formLabel {
|
|
||||||
margin-top: 40px;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 18px 24px;
|
|
||||||
border-radius: 50px;
|
|
||||||
border: none;
|
|
||||||
font-size: 18px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
height: 56px !important;
|
|
||||||
padding: 0 36px !important;
|
|
||||||
border-radius: 50px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- MOBILE --- */
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.inner {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left,
|
|
||||||
.right {
|
|
||||||
max-width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phoneBlock {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phoneInfo {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./QuestionsBlock.module.css";
|
|
||||||
import Button from "../Button/Button";
|
|
||||||
import { PhoneOutlined } from "@ant-design/icons";
|
|
||||||
|
|
||||||
const QuestionsBlock: React.FC = () => {
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
console.log('Кнопка нажата!');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={styles.wrapper}>
|
|
||||||
<div className={styles.inner}>
|
|
||||||
{/* LEFT TEXT */}
|
|
||||||
<div className={styles.left}>
|
|
||||||
<h2 className={styles.title}>ЕСТЬ ВОПРОСЫ?</h2>
|
|
||||||
|
|
||||||
<p className={styles.text}>
|
|
||||||
Мы готовы ответить на ваши вопросы <br />
|
|
||||||
и предоставить всю необходимую информацию <br />
|
|
||||||
о сотрудничестве и товаре.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT CONTENT */}
|
|
||||||
<div className={styles.right}>
|
|
||||||
<div className={styles.phoneBlock}>
|
|
||||||
<PhoneOutlined className={styles.phoneIcon} />
|
|
||||||
<span className={styles.phoneNumber}>8 800 707 25 58</span>
|
|
||||||
|
|
||||||
<div className={styles.phoneInfo}>
|
|
||||||
Звоните с понедельника по пятницу <br /> c 09:00 до 18:00 (по Москве)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className={styles.formLabel}>
|
|
||||||
Или оставьте заявку и мы вам перезвоним
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* FORM */}
|
|
||||||
<div className={styles.form}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="+7 (___) ___-__-__"
|
|
||||||
className={styles.input}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="ОТПРАВИТЬ"
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QuestionsBlock;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
.catalogWrapper {
|
|
||||||
display: flex;
|
|
||||||
padding: 20px;
|
|
||||||
gap: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.productsSection {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e5e5e5;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.3s;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card img {
|
|
||||||
max-width: 160px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plus {
|
|
||||||
position: absolute;
|
|
||||||
right: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
font-size: 22px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ffcc00;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.catalogWrapper {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./CategoryCards.module.css";
|
|
||||||
import { useAppSelector } from "../../app/hooks";
|
|
||||||
|
|
||||||
interface Props { onSelect?: (id:number) => void }
|
|
||||||
const CategoryCards: React.FC<Props> = ({ onSelect }) => {
|
|
||||||
const categories = useAppSelector(s => s.categories.items);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{categories.map(c => (
|
|
||||||
<div key={c.id} className={styles.card} onClick={() => onSelect?.(c.id)}>
|
|
||||||
{c.image ? <img src={c.image} alt={c.name} className={styles.img} /> : <img src="/mnt/data/c9552f5e-1741-4845-8d5f-9d438f068636.png" alt={c.name} className={styles.img} />}
|
|
||||||
<div className={styles.name}>{c.name}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default CategoryCards;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./CategoryCards.module.css";
|
|
||||||
import { useAppSelector } from "../../app/hooks";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelect?: (id:number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CategoryCards: React.FC<Props> = ({ onSelect }) => {
|
|
||||||
const categories = useAppSelector(s => s.categories.items);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.grid}>
|
|
||||||
{categories.map(c => (
|
|
||||||
<div key={c.id} className={styles.card} onClick={() => onSelect?.(c.id)}>
|
|
||||||
{c.image ? <img src={c.image} alt={c.name} className={styles.img} /> : <img src="/mnt/data/c9552f5e-1741-4845-8d5f-9d438f068636.png" alt={c.name} className={styles.img} />}
|
|
||||||
<div className={styles.name}>{c.name}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default CategoryCards;
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useAppSelector, useAppDispatch } from "../../app/hooks";
|
|
||||||
import { setSelectedCategory } from "./categoriesSlice";
|
|
||||||
import styles from "./CategoriesSidebar.module.css";
|
|
||||||
import { setFilters, fetchProductsByCategory, setPage } from "./productsSlice";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const CategoryFilters = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { items: cats, selectedCategoryId } = useAppSelector(s => s.categories);
|
|
||||||
const products = useAppSelector(s => s.products);
|
|
||||||
const cat = cats.find(x => x.id === selectedCategoryId);
|
|
||||||
const [local, setLocal] = useState<Record<string, (string|number)[]>>({});
|
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
dispatch(setFilters(local));
|
|
||||||
dispatch(setPage(1));
|
|
||||||
if (selectedCategoryId) dispatch(fetchProductsByCategory({ categoryId: selectedCategoryId, page: 1, limit: products.limit, filters: local }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!cat) return null;
|
|
||||||
return (
|
|
||||||
<div className={styles.wrap}>
|
|
||||||
<h4 className={styles.title}>ПАРАМЕТРЫ</h4>
|
|
||||||
<div className={styles.blocks}>
|
|
||||||
{cat.atributes.map(attr => (
|
|
||||||
<div key={attr.id} className={styles.block}>
|
|
||||||
<div className={styles.blockTitle}>{`${attr.name}:`}</div>
|
|
||||||
<div className={styles.options}>
|
|
||||||
{attr.vals.map(v => {
|
|
||||||
const key = `attr_${attr.id}`;
|
|
||||||
const active = local[key]?.includes(v.val);
|
|
||||||
return (
|
|
||||||
<label key={v.id} className={styles.opt}>
|
|
||||||
<input type="checkbox"
|
|
||||||
checked={!!active}
|
|
||||||
onChange={() => {
|
|
||||||
const cur = local[key] ?? [];
|
|
||||||
const next = cur.includes(v.val) ? cur.filter(x => x !== v.val) : [...cur, v.val];
|
|
||||||
setLocal({ ...local, [key]: next });
|
|
||||||
}} />
|
|
||||||
{v.val}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button className={styles.apply} onClick={apply}>Показать</button>
|
|
||||||
<button className={styles.reset} onClick={() => { setLocal({}); dispatch(setFilters({})); dispatch(setPage(1)); if (selectedCategoryId) dispatch(fetchProductsByCategory({ categoryId: selectedCategoryId, page: 1, limit: products.limit })); }}>Сбросить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CategoryFilters;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./Pagination.module.css";
|
|
||||||
import { useAppSelector, useAppDispatch } from "../../app/hooks";
|
|
||||||
import { setPage, fetchProductsByCategory } from "./productsSlice";
|
|
||||||
|
|
||||||
interface Props { categoryId: number }
|
|
||||||
|
|
||||||
const Pagination: React.FC<Props> = ({ categoryId }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { total, page, limit } = useAppSelector(s => s.products);
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
if (totalPages <= 1) return null;
|
|
||||||
|
|
||||||
const go = (p:number) => {
|
|
||||||
dispatch(setPage(p));
|
|
||||||
dispatch(fetchProductsByCategory({ categoryId, page: p, limit }));
|
|
||||||
window.scrollTo({ top: 200, behavior: "smooth" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrap}>
|
|
||||||
<button className={styles.go} onClick={() => go(1)} disabled={page===1}>В НАЧАЛО</button>
|
|
||||||
|
|
||||||
<div className={styles.pages}>
|
|
||||||
{Array.from({length: totalPages}, (_,i) => i+1).map(p => (
|
|
||||||
<button key={p} className={`${styles.page} ${p===page?styles.active:""}`} onClick={() => go(p)}>{p}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className={styles.go} onClick={() => go(totalPages)} disabled={page===totalPages}>В КОНЕЦ</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Pagination;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { Product } from "../../types/product";
|
|
||||||
import styles from "./Catalog.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
product: Product;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductCard: React.FC<Props> = ({ product }) => {
|
|
||||||
return (
|
|
||||||
<div className={styles.card}>
|
|
||||||
<img src={product.image} alt={product.name} />
|
|
||||||
<p className={styles.name}>{product.name}</p>
|
|
||||||
<button className={styles.plus}>+</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductCard;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import styles from "./SidebarCategories.module.css";
|
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
|
||||||
import { fetchCategories, setSelectedCategory } from "./categoriesSlice";
|
|
||||||
import { fetchProductsByCategory, setFilters, setPage } from "./productsSlice";
|
|
||||||
|
|
||||||
const SidebarCategories: React.FC = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { items, selectedCategoryId, loading } = useAppSelector(s => s.categories);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchCategories());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const onClick = (catId: number) => {
|
|
||||||
dispatch(setSelectedCategory(catId));
|
|
||||||
dispatch(setFilters({})); // сброс фильтров
|
|
||||||
dispatch(setPage(1));
|
|
||||||
dispatch(fetchProductsByCategory({ categoryId: catId, page: 1, limit: 9 }));
|
|
||||||
};
|
|
||||||
return(
|
|
||||||
<aside className={styles.sidebar}>
|
|
||||||
<h3 className={styles.title}>КАТЕГОРИИ ТОВАРОВ</h3>
|
|
||||||
{loading ? <p>Загрузка...</p> : (
|
|
||||||
<ul className={styles.list}>
|
|
||||||
{items.map(cat => (
|
|
||||||
<li key={cat.id}
|
|
||||||
className={`${styles.item} ${selectedCategoryId === cat.id ? styles.active : ""}`}
|
|
||||||
onClick={() => onClick(cat.id)}>
|
|
||||||
{cat.name}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SidebarCategories;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
const About = () =>{
|
|
||||||
return(
|
|
||||||
<>
|
|
||||||
<p>gggggggg</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default About;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
.container{
|
|
||||||
width: 80%;
|
|
||||||
margin: 0;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main{
|
|
||||||
margin: 0;
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import styles from "./ProductsPage.module.css";
|
|
||||||
import SidebarCategories from "../../features/catalog/SidebarCategories";
|
|
||||||
import CategoryCards from "../../features/catalog/CategoryCards";
|
|
||||||
import Filters from "../../features/catalog/CategoryFilters";
|
|
||||||
import ProductGrid from "../../features/catalog/ProductGrid";
|
|
||||||
import Pagination from "../../features/catalog/Pagination";
|
|
||||||
import { useAppSelector } from "../../app/hooks";
|
|
||||||
|
|
||||||
const ProductsPage: React.FC = () => {
|
|
||||||
const { selectedCategoryId } = useAppSelector(s => s.categories);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<SidebarCategories />
|
|
||||||
<div className={styles.content}>
|
|
||||||
{!selectedCategoryId ? (
|
|
||||||
<CategoryCards onSelect={(id) => {}} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.leftControls}>
|
|
||||||
<Filters />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.productsArea}>
|
|
||||||
<ProductGrid />
|
|
||||||
<Pagination categoryId={selectedCategoryId} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductsPage;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface Product {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/jpg" href="/public/MiniLogo.jpg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>front</title>
|
<title>RK PLASTIC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -5,8 +5,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "vite build",
|
||||||
|
"build:check": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"prerender": "node scripts/prerender.js",
|
||||||
|
"build:seo": "npm run build && npm run preview & sleep 5 && npm run prerender && kill $(lsof -t -i:4173)",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,6 +21,7 @@
|
|||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-helmet-async": "^3.0.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-input-mask": "^2.0.4",
|
"react-input-mask": "^2.0.4",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
@@ -32,12 +36,15 @@
|
|||||||
"@types/react-input-mask": "^3.0.6",
|
"@types/react-input-mask": "^3.0.6",
|
||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"puppeteer": "^24.40.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.45.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7",
|
||||||
|
"vite-plugin-sitemap": "^0.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://pvh.projects.rustprogersteam.ru/sitemap.xml
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function getProducts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://pvh.projects.rustprogersteam.ru/api/productList');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
console.log(`✅ Загружено ${data.length} товаров`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем товары
|
||||||
|
const products = await getProducts();
|
||||||
|
|
||||||
|
const distPath = resolve(__dirname, "../dist");
|
||||||
|
const indexHtmlPath = resolve(distPath, "index.html");
|
||||||
|
|
||||||
|
if (!existsSync(indexHtmlPath)) {
|
||||||
|
console.error("❌ Сначала запустите npm run build");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем исходный HTML
|
||||||
|
let baseHtml = readFileSync(indexHtmlPath, "utf-8");
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ФУНКЦИИ ДЛЯ ГЕНЕРАЦИИ КОНТЕНТА
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация страницы товара
|
||||||
|
function generateProductPage(product) {
|
||||||
|
return `
|
||||||
|
<div class="product-page">
|
||||||
|
<h1>${escapeHtml(product.full_name)}</h1>
|
||||||
|
<div class="product-description">
|
||||||
|
<p>${escapeHtml(product.description || 'Описание отсутствует')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="product-details">
|
||||||
|
<p><strong>Артикул:</strong> ${product.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация главной страницы
|
||||||
|
function generateHomePage(products) {
|
||||||
|
const topProducts = products.slice(0, 6).map(product => `
|
||||||
|
<div class="product-card">
|
||||||
|
<h3>${escapeHtml(product.full_name || `Товар ${product.id}`)}</h3>
|
||||||
|
<a href="/product/${product.slug || product.id}">Подробнее</a>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="home-page">
|
||||||
|
<h1>Добро пожаловать в магазин труб RK PLASTIC technology!</h1>
|
||||||
|
<p>Широкий выбор канализационных труб, фитингов, колен, отводов, муфт, переходов, заглушек, тройников и ревизий.</p>
|
||||||
|
<h2>Популярные товары</h2>
|
||||||
|
<div class="products-grid">
|
||||||
|
${topProducts}
|
||||||
|
</div>
|
||||||
|
<a href="/products" class="catalog-link">Перейти в каталог</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация страницы каталога
|
||||||
|
function generateCatalogPage(products) {
|
||||||
|
const allProducts = products.map(product => `
|
||||||
|
<div class="product-card">
|
||||||
|
<h2>${escapeHtml(product.full_name || `Товар ${product.id}`)}</h2>
|
||||||
|
<p>${escapeHtml((product.description || '').slice(0, 150))}...</p>
|
||||||
|
<a href="/product/${product.slug || product.id}">Подробнее</a>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="catalog-page">
|
||||||
|
<h1>Каталог товаров</h1>
|
||||||
|
<div class="product-list">
|
||||||
|
${allProducts}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация страницы "О нас"
|
||||||
|
function generateAboutPage() {
|
||||||
|
return `
|
||||||
|
<div class="about-page">
|
||||||
|
<h1>О магазине</h1>
|
||||||
|
<p>Мы занимаемся продажей качественных труб и остальных комплектующих для канализации.</p>
|
||||||
|
<h2>Наши преимущества:</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Низкие цены</li>
|
||||||
|
<li>Быстрая доставка</li>
|
||||||
|
<li>Гарантия качества</li>
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
title: "Магазин труб - Главная",
|
||||||
|
description: "Купить канализационные трубы, фитинги и комплектующие по низким ценам.",
|
||||||
|
getContent: () => generateHomePage(products)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/products",
|
||||||
|
title: "Каталог труб и фитингов для канализации",
|
||||||
|
description: "Широкий выбор канализационных труб ПВХ, фитингов, тройников, переходов, колен.",
|
||||||
|
getContent: () => generateCatalogPage(products)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/about",
|
||||||
|
title: "О магазине труб - Доставка и гарантия",
|
||||||
|
description: "Информация о нашей компании. Как мы работаем, условия доставки.",
|
||||||
|
getContent: () => generateAboutPage()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Добавляем страницы товаров
|
||||||
|
for (const product of products) {
|
||||||
|
routes.push({
|
||||||
|
path: `/product/${product.slug || product.id}`,
|
||||||
|
title: product.full_name,
|
||||||
|
description: (product.description || ''),
|
||||||
|
getContent: () => generateProductPage(product)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📄 Всего страниц для генерации: ${routes.length}\n`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
try {
|
||||||
|
// Генерируем контент для этой страницы
|
||||||
|
const pageContent = route.getContent();
|
||||||
|
|
||||||
|
// Вставляем контент в #root
|
||||||
|
let html = baseHtml;
|
||||||
|
|
||||||
|
// Меняем title
|
||||||
|
html = html.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(route.title)}</title>`);
|
||||||
|
|
||||||
|
// Меняем meta description
|
||||||
|
html = html.replace(/<meta name="description".*?>/, `<meta name="description" content="${escapeHtml(route.description)}">`);
|
||||||
|
|
||||||
|
// Вставляем контент в #root (ВАЖНО!)
|
||||||
|
html = html.replace('<div id="root"></div>', `<div id="root">${pageContent}</div>`);
|
||||||
|
|
||||||
|
// Добавляем Open Graph теги
|
||||||
|
html = html.replace(
|
||||||
|
"</head>",
|
||||||
|
` <meta property="og:title" content="${escapeHtml(route.title)}">
|
||||||
|
<meta property="og:description" content="${escapeHtml(route.description)}">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
</head>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Определяем путь для сохранения
|
||||||
|
let outputPath;
|
||||||
|
if (route.path === "/") {
|
||||||
|
outputPath = indexHtmlPath;
|
||||||
|
} else {
|
||||||
|
const routeDir = resolve(distPath, route.path.substring(1));
|
||||||
|
if (!existsSync(routeDir)) {
|
||||||
|
mkdirSync(routeDir, { recursive: true });
|
||||||
|
}
|
||||||
|
outputPath = resolve(routeDir, "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(outputPath, html);
|
||||||
|
console.log(`✅ ${route.path}`);
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Ошибка при генерации ${route.path}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = routes.map(route => ({
|
||||||
|
url: route.path,
|
||||||
|
lastmod: new Date().toISOString().split('T')[0],
|
||||||
|
priority: route.path === '/' ? 1.0 : route.path.startsWith('/product') ? 0.8 : 0.7
|
||||||
|
}));
|
||||||
|
|
||||||
|
function generateSitemap(urls, baseUrl) {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls.map(item => ` <url>
|
||||||
|
<loc>${baseUrl}${item.url}</loc>
|
||||||
|
<lastmod>${item.lastmod}</lastmod>
|
||||||
|
<priority>${item.priority}</priority>
|
||||||
|
</url>`).join('\n')}
|
||||||
|
</urlset>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitemapContent = generateSitemap(urls, 'https://pvh.projects.rustprogersteam.ru');
|
||||||
|
const sitemapPath = resolve(distPath, 'sitemap.xml');
|
||||||
|
writeFileSync(sitemapPath, sitemapContent);
|
||||||
|
|
||||||
|
console.log(`\n✨ Готово! Сгенерировано ${successCount} страниц.`);
|
||||||
|
console.log(`🗺️ Sitemap: ${sitemapPath}`);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios from "axios";
|
/*import axios from "axios";
|
||||||
import type { Product } from "../types/product";
|
import type { Product } from "../types/product";
|
||||||
import type { Category } from "../types/categories";
|
import type { Category } from "../types/categories";
|
||||||
|
|
||||||
@@ -10,5 +10,5 @@ export const fetchCategoryProducts = async (category: string): Promise<Product[]
|
|||||||
export const fetchCategories = async (categoryid: string): Promise<Category[]> => {
|
export const fetchCategories = async (categoryid: string): Promise<Category[]> => {
|
||||||
const response = await axios.get(`https://your-api.com/api/category=${categoryid}`);
|
const response = await axios.get(`https://your-api.com/api/category=${categoryid}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};*/
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(baseURL: string = 'https://pvh.projects.rustprogersteam.ru/api') {
|
||||||
|
this.axiosInstance = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) => status >= 200 && status < 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
// Если это редирект (301, 302, 307, 308)
|
||||||
|
if (error.response && [301, 302, 307, 308].includes(error.response.status)) {
|
||||||
|
const location = error.response.headers.location;
|
||||||
|
console.log('🔄 Redirect detected:', location);
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
// Исправляем HTTP на HTTPS
|
||||||
|
let fixedLocation = location;
|
||||||
|
if (fixedLocation.startsWith('http://')) {
|
||||||
|
fixedLocation = fixedLocation.replace('http://', 'https://');
|
||||||
|
console.log('✅ Fixed URL:', fixedLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторяем запрос на исправленный URL
|
||||||
|
try {
|
||||||
|
const response = await this.axiosInstance.get(fixedLocation);
|
||||||
|
return response;
|
||||||
|
} catch (retryError) {
|
||||||
|
return Promise.reject(retryError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
this.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
console.error('Resource not found:', error.config?.url);
|
||||||
|
} else if (error.response?.status === 500) {
|
||||||
|
console.error('Server error:', error.response?.data);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(url: string): Promise<{ data: T }> {
|
||||||
|
const response = await this.axiosInstance.get<T>(url);
|
||||||
|
return { data: response.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(
|
||||||
|
url: string,
|
||||||
|
data?: any,
|
||||||
|
): Promise<{ data: T }> {
|
||||||
|
const response = await this.axiosInstance.post<T>(url, data);
|
||||||
|
return { data: response.data };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = new ApiClient();
|
||||||
@@ -3,7 +3,9 @@ import { Routes } from "react-router";
|
|||||||
import { Route } from "react-router";
|
import { Route } from "react-router";
|
||||||
import Main from "../pages/Main/Main";
|
import Main from "../pages/Main/Main";
|
||||||
import About from "../pages/About/About";
|
import About from "../pages/About/About";
|
||||||
|
import Contacts from "../pages/Contacts/Contacts";
|
||||||
import Products from "../pages/Products/Products";
|
import Products from "../pages/Products/Products";
|
||||||
|
import ItemPage from "../pages/ItemPage/ItemPage";
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -12,6 +14,8 @@ function AppRoutes() {
|
|||||||
<Route index element={<Main />} />
|
<Route index element={<Main />} />
|
||||||
<Route path="about" element={<About />} />
|
<Route path="about" element={<About />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
|
<Route path="contacts" element={<Contacts />} />
|
||||||
|
<Route path="/product/:slug" element={<ItemPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import catalogReducer from "../features/catalog/productsSlice";
|
|
||||||
import categoriesReducer from "../features/catalog/categoriesSlice"
|
import categoriesReducer from "../features/catalog/categoriesSlice"
|
||||||
import productsReducer from "../features/catalog/productsSlice"
|
import productsReducer from "../features/catalog/productsSlice"
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 955 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -29,20 +29,25 @@ top: 180px;
|
|||||||
color: white;
|
color: white;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.textBlock h1 {
|
.textBlock h1 {
|
||||||
font-size: 36px;
|
font-size: 42px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
line-height: 1.3;
|
line-height: 1.14;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.textBlock h2 {
|
.textBlock h2 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
font-weight: 700;
|
||||||
opacity: 0.8;
|
opacity: 0.9;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -62,9 +67,30 @@ width: 100%;
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: center;
|
object-position: center;
|
||||||
|
filter: brightness(0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Mobile Adaptive --- */
|
/* --- Mobile Adaptive --- */
|
||||||
|
|
||||||
|
@media (max-width: 890px){
|
||||||
|
.slide {
|
||||||
|
height: 600px;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 665px){
|
||||||
|
.textBlock h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.textBlock h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.slide {
|
.slide {
|
||||||
height: 500px;
|
height: 500px;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Carousel } from "antd";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import styles from "./Banner.module.css";
|
||||||
|
import Button from "../Button/Button.tsx";
|
||||||
|
import banner_img1 from "../../assets/images/mainPage/ChatGPT-Image-Oct-1_-2025_-05_29_18-PM.png";
|
||||||
|
import banner_img2 from "../../assets/images/mainPage/296170994030930.jpg";
|
||||||
|
|
||||||
|
export interface SlideItem {
|
||||||
|
id: number;
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slides: SlideItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
image: banner_img1,
|
||||||
|
title: "Производство",
|
||||||
|
subtitle: "Труб, фитингов, колен и отводов",
|
||||||
|
description: "для наружной и внутренней канализации",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
image: banner_img2,
|
||||||
|
title: "Производство",
|
||||||
|
subtitle: "Труб, фитингов, колен и отводов",
|
||||||
|
description: "для наружной и внутренней канализации",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BannerSlider: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate("/products");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Carousel autoplay dots className={styles.carousel}>
|
||||||
|
{slides.map((slide) => (
|
||||||
|
<div key={slide.id}>
|
||||||
|
<div className={styles.slide}>
|
||||||
|
<div className={styles.textBlock}>
|
||||||
|
<h2>{slide.title}</h2>
|
||||||
|
<h1>
|
||||||
|
{slide.subtitle}
|
||||||
|
<br /> {slide.description}
|
||||||
|
</h1>
|
||||||
|
<Button label="Смотреть каталог" onClick={handleClick} />
|
||||||
|
</div>
|
||||||
|
<img src={slide.image} alt="banner" className={styles.image} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BannerSlider;
|
||||||
@@ -13,10 +13,18 @@ font-size: 15px;
|
|||||||
|
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background: #ffdf3e;
|
background: #ffe667;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.button:active {
|
.button:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px){
|
||||||
|
.button{
|
||||||
|
padding: 0 30px;
|
||||||
|
font-size: 12px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
max-width: 760px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin: 0;
|
||||||
|
color: #111111;
|
||||||
|
font-size: clamp(30px, 4vw, 46px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head p {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 420px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 18px 50px rgba(17, 31, 51, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 28px;
|
||||||
|
right: 28px;
|
||||||
|
bottom: 28px;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #151515;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #111111;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay h4 {
|
||||||
|
margin: 8px 0 22px;
|
||||||
|
color: #596474;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .image {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.head p {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.image {
|
||||||
|
min-height: 330px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
left: 18px;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay h3 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay h4 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import styles from "./Categories.module.css";
|
||||||
|
import Button from "../Button/Button";
|
||||||
|
import category_internal1 from "../../assets/images/mainPage/category_internal1.jpg";
|
||||||
|
import category_interna2 from "../../assets/images/mainPage/category_internal2.jpg";
|
||||||
|
|
||||||
|
interface CategoryItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
image: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: CategoryItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Труба ПВХ",
|
||||||
|
subtitle: "для наружной канализации",
|
||||||
|
image: category_interna2,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: "Труба ПВХ",
|
||||||
|
subtitle: "для внутренней канализации",
|
||||||
|
image: category_internal1,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CategoriesGrid: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate("/products");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.wrapper}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
<span className={styles.kicker}>Продукция</span>
|
||||||
|
<h2 className={styles.header}>Категории товаров</h2>
|
||||||
|
<p>
|
||||||
|
Основные позиции для наружной и внутренней канализации. Полный каталог
|
||||||
|
доступен в разделе продукции.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<article
|
||||||
|
key={cat.id}
|
||||||
|
className={`${styles.card} ${cat.highlight ? styles.highlight : ""}`}
|
||||||
|
>
|
||||||
|
<img src={cat.image} className={styles.image} alt={cat.title} />
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<h3>{cat.title}</h3>
|
||||||
|
{cat.subtitle && <h4>{cat.subtitle}</h4>}
|
||||||
|
<Button label="Смотреть каталог" onClick={handleClick} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoriesGrid;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0px 0;
|
padding: 0px 0;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
@@ -19,6 +20,25 @@
|
|||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title{
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle{
|
||||||
|
margin: 0 0 28px;
|
||||||
|
color: #111111;
|
||||||
|
font-size: clamp(30px, 4vw, 46px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
.carousel {
|
.carousel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%; /* Добавьте высоту 100% */
|
height: 100%; /* Добавьте высоту 100% */
|
||||||
@@ -43,7 +63,10 @@ position: relative;
|
|||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 40px;
|
left: 40px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 22px;
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrows {
|
.arrows {
|
||||||
@@ -59,7 +82,9 @@ position: relative;
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,3 +104,17 @@ position: relative;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 665px){
|
||||||
|
.title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import styles from "./ContactProduct.module.css";
|
|||||||
import Button from "../Button/Button";
|
import Button from "../Button/Button";
|
||||||
import { Carousel } from "antd";
|
import { Carousel } from "antd";
|
||||||
import ModalRequestForm from "../ModalRequestForm/ModalRequestForm";
|
import ModalRequestForm from "../ModalRequestForm/ModalRequestForm";
|
||||||
const imageUrl = "http://sprint-pro.com/images/banners/slide1/t1.png";
|
const imageUrl = "https://sprint-pro.com/images/banners/slide1/t1.png";
|
||||||
|
import image1 from "../../assets/images/mainPage/_.jpg"
|
||||||
|
import image2 from "../../assets/images/mainPage/orig.jpg"
|
||||||
|
|
||||||
interface ProductionSlide {
|
interface ProductionSlide {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,13 +16,13 @@ interface ProductionSlide {
|
|||||||
const slides: ProductionSlide[] = [
|
const slides: ProductionSlide[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
image: "/images/prod1.jpg",
|
image: image1,
|
||||||
label: "Аэрозольная упаковка",
|
label: ".",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
image: "/images/prod2.jpg",
|
image: image2,
|
||||||
label: "Металлические баллоны",
|
label: ".",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ const ProductionBanner: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{slides.map((s) => (
|
{slides.map((s) => (
|
||||||
<div key={s.id} className={styles.slide}>
|
<div key={s.id} className={styles.slide}>
|
||||||
<img src={imageUrl} className={styles.image} alt="prod" />
|
<img src={s.image} className={styles.image} alt="prod" />
|
||||||
<div className={styles.label}>{s.label}</div>
|
<div className={styles.label}>{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
.footer {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 90%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 60px;
|
||||||
|
opacity: 0.9;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.center a {
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.center a:hover {
|
||||||
|
color: #484848;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.right {
|
||||||
|
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail:hover {
|
||||||
|
color: #525252;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.container {
|
||||||
|
|
||||||
|
gap: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.right {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px){
|
||||||
|
.logo {
|
||||||
|
width: 40px;
|
||||||
|
opacity: 0.9;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.left {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.center a:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px){
|
||||||
|
.logo {
|
||||||
|
width: 40px;
|
||||||
|
opacity: 0.9;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.left {
|
||||||
|
gap: 10px;
|
||||||
|
width: 30%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy {
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.center a:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mail:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "./Footer.module.css";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import blackLogo from "../../assets/images/BlackLogo.jpg";
|
||||||
|
import PriceLisForm from "../PriceLisForm/PriceLisForm";
|
||||||
|
import { useState } from "react";
|
||||||
|
import OrangeLogo from "../../assets/images/OrangeLogo.jpg";
|
||||||
|
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<img src={blackLogo} alt="logo" className={styles.logo} />
|
||||||
|
<span className={styles.copy}>RK-Plastic Tech. 2021-2026 ©</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.center}>
|
||||||
|
<NavLink to="/about">O КОМПАНИИ</NavLink>
|
||||||
|
<NavLink to="/products">ПРОДУКЦИЯ</NavLink>
|
||||||
|
<NavLink to="/contacts">КОНТАКТЫ</NavLink>
|
||||||
|
<NavLink to="/products" onClick={() => setIsOpen(true)}>ПРАЙС-ЛИСТ</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.right}>
|
||||||
|
<div className={styles.phone}>+7 831 410-90-89</div>
|
||||||
|
<a className={styles.mail}>rkplastic@mail.ru</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PriceLisForm open={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
color: black;
|
color: black;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 1.8rem 2rem;
|
padding: 1.2rem 1.2rem;
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 1.1rem;
|
font-size: 1.0rem;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
.headerMenu a:nth-child(1) { animation-delay: 0.1s; }
|
.headerMenu a:nth-child(1) { animation-delay: 0.1s; }
|
||||||
.headerMenu a:nth-child(2) { animation-delay: 0.2s; }
|
.headerMenu a:nth-child(2) { animation-delay: 0.2s; }
|
||||||
.headerMenu a:nth-child(3) { animation-delay: 0.3s; }
|
.headerMenu a:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
.headerMenu a:nth-child(4) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
to {
|
to {
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.headerMenu {
|
.headerMenu {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerMenu a {
|
.headerMenu a {
|
||||||
@@ -144,3 +145,83 @@
|
|||||||
.headerMenu a[onclick]:hover::before {
|
.headerMenu a[onclick]:hover::before {
|
||||||
background: linear-gradient(90deg, #ffd89b, #ff6b6b);
|
background: linear-gradient(90deg, #ffd89b, #ff6b6b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*бургер*/
|
||||||
|
.burger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burger span {
|
||||||
|
width: 25px;
|
||||||
|
height: 3px;
|
||||||
|
background: black;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация крестика */
|
||||||
|
.burger.active span:nth-child(1) {
|
||||||
|
transform: rotate(45deg) translate(5px, 5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.burger.active span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burger.active span:nth-child(3) {
|
||||||
|
transform: rotate(-45deg) translate(6px, -6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1090px) {
|
||||||
|
|
||||||
|
.burger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerMenu {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerNav {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerNav.open {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerNav a {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneBlock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px){
|
||||||
|
.logoImage {
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
/*filter: brightness(0) invert(1);*/
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import styles from './Header.module.css'
|
||||||
|
import logo from '../../assets/images/Logo.jpg'
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAppDispatch } from "../../app/hooks";
|
||||||
|
import { setSelectedCategory } from "../../features/catalog/categoriesSlice";
|
||||||
|
|
||||||
|
const Header = () =>{
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.header}>
|
||||||
|
<div className={styles.headerMenu}>
|
||||||
|
|
||||||
|
<div className={styles.headeLogo}>
|
||||||
|
<a href="/" style={{padding: 5}}><img src={logo} alt="logo" className={styles.logoImage}/></a>
|
||||||
|
<div className={styles.phoneBlock}>
|
||||||
|
<a href="">+7 831 410-90-89</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* БУРГЕР */}
|
||||||
|
<div
|
||||||
|
className={`${styles.burger} ${menuOpen ? styles.active : ""}`}
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* НАВИГАЦИЯ */}
|
||||||
|
<div className={`${styles.headerNav} ${menuOpen ? styles.open : ""}`}>
|
||||||
|
<NavLink to="/" end onClick={() => setMenuOpen(false)}>ГЛАВНАЯ</NavLink>
|
||||||
|
<NavLink to="/about" onClick={() => setMenuOpen(false)}>О КОМПАНИИ</NavLink>
|
||||||
|
<NavLink to="/products" onClick={() => {setMenuOpen(false); dispatch(setSelectedCategory(null))}}>ПРОДУКЦИЯ</NavLink>
|
||||||
|
<NavLink to="/contacts" onClick={() => setMenuOpen(false)}>КОНТАКТЫ</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
.modalRoot :global(.ant-modal-content) {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
|
||||||
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalRoot :global(.ant-modal-close) {
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #222;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalRoot :global(.ant-modal-close:hover) {
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBox {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 46px 44px 38px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBox::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px solid rgba(255, 212, 0, 0.35);
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrap {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 78px;
|
||||||
|
height: 78px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff4a8;
|
||||||
|
box-shadow: 0 14px 30px rgba(255, 212, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: #111111;
|
||||||
|
font-size: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
max-width: 350px;
|
||||||
|
margin: 12px 0 28px;
|
||||||
|
color: #636363;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.modalRoot :global(.ant-modal) {
|
||||||
|
max-width: calc(100vw - 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalBox {
|
||||||
|
padding: 40px 24px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrap {
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CheckCircleFilled } from "@ant-design/icons";
|
||||||
|
import { Modal } from "antd";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import React from "react";
|
||||||
|
import styles from "./Modal.module.css";
|
||||||
|
import Button from "../Button/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modalka: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
footer={null}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered
|
||||||
|
width={460}
|
||||||
|
rootClassName={styles.modalRoot}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 28, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
|
className={styles.modalBox}
|
||||||
|
>
|
||||||
|
<div className={styles.iconWrap}>
|
||||||
|
<CheckCircleFilled className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.title}>Спасибо за обращение!</p>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Мы свяжемся с вами в ближайшее время. Хорошего дня!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button onClick={onClose} label="Хорошо"/>
|
||||||
|
</motion.div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modalka;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Modal } from "antd";
|
import { Modal } from "antd";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import InputMask from "react-input-mask";
|
import InputMask from "react-input-mask";
|
||||||
@@ -7,6 +7,9 @@ import * as yup from "yup";
|
|||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import styles from "./ModalRequestForm.module.css";
|
import styles from "./ModalRequestForm.module.css";
|
||||||
import Button from "../Button/Button";
|
import Button from "../Button/Button";
|
||||||
|
import { client } from "../../api/client";
|
||||||
|
import Modalka from "../Modal/Modal";
|
||||||
|
|
||||||
|
|
||||||
// ✅ Схема валидации
|
// ✅ Схема валидации
|
||||||
const schema = yup.object().shape({
|
const schema = yup.object().shape({
|
||||||
@@ -29,6 +32,8 @@ interface FormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ModalRequestForm: React.FC<Props> = ({ open, onClose }) => {
|
const ModalRequestForm: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
console.log('ModalRequestForm render, open:', open);
|
console.log('ModalRequestForm render, open:', open);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -65,24 +70,13 @@ const ModalRequestForm: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
// ✅ Отправка данных на бэкенд
|
// ✅ Отправка данных на бэкенд
|
||||||
const sendToBackend = async (data: FormData): Promise<boolean> => {
|
const sendToBackend = async (data: FormData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/requests`, {
|
const payload = {
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
...data,
|
...data,
|
||||||
source: "website-form",
|
};
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await client.post<{ success: boolean }>("/email/", payload);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
return response.data.success;
|
||||||
return result.success;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error sending request:", error);
|
console.error("Error sending request:", error);
|
||||||
throw new Error("Не удалось отправить заявку. Попробуйте позже.");
|
throw new Error("Не удалось отправить заявку. Попробуйте позже.");
|
||||||
@@ -103,6 +97,7 @@ const ModalRequestForm: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
reset();
|
reset();
|
||||||
onClose();
|
onClose();
|
||||||
|
setIsOpen(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Показываем ошибку
|
// Показываем ошибку
|
||||||
Modal.error({
|
Modal.error({
|
||||||
@@ -114,6 +109,8 @@ const ModalRequestForm: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Modalka open={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
footer={null}
|
footer={null}
|
||||||
@@ -184,18 +181,18 @@ const ModalRequestForm: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className={styles.textarea}
|
className={styles.textarea}
|
||||||
placeholder="Задайте свой вопрос"
|
placeholder="Сделайте запрос"
|
||||||
{...register("message")}
|
{...register("message")}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label={isSubmitting ? "ОТПРАВКА..." : "СДЕЛАТЬ ЗАПРОС"}
|
label={isSubmitting ? "ОТПРАВКА..." : "СДЕЛАТЬ ЗАПРОС"}
|
||||||
className={styles.button}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.modalBox {
|
||||||
|
padding: 10px 20px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 93%;
|
||||||
|
padding: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #f9f9f9;
|
||||||
|
outline: none;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: #ffd400;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
height: 120px;
|
||||||
|
resize: none;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #f9f9f9;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #d33;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -18px;
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 58px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Modal } from "antd";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import styles from "./PriceLisForm.module.css";
|
||||||
|
import Button from "../Button/Button";
|
||||||
|
import { client } from "../../api/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Modalka from "../Modal/Modal";
|
||||||
|
|
||||||
|
// ✅ Схема валидации
|
||||||
|
const schema = yup.object().shape({
|
||||||
|
name: yup.string().required("Введите ФИО"),
|
||||||
|
email: yup.string().email("Некорректный Email").required("Введите Email"),
|
||||||
|
phone: yup.string().min(18, "Введите корректный номер").required("Введите номер"),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PriceLisForm: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
console.log('ModalRequestForm render, open:', open);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
reset,
|
||||||
|
} = useForm({ resolver: yupResolver(schema) });
|
||||||
|
|
||||||
|
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let value = e.target.value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (value.startsWith('7') || value.startsWith('8')) {
|
||||||
|
value = value.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedValue = '+7 ';
|
||||||
|
|
||||||
|
if (value.length > 0) {
|
||||||
|
formattedValue += '(' + value.substring(0, 3);
|
||||||
|
}
|
||||||
|
if (value.length > 3) {
|
||||||
|
formattedValue += ') ' + value.substring(3, 6);
|
||||||
|
}
|
||||||
|
if (value.length > 6) {
|
||||||
|
formattedValue += '-' + value.substring(6, 8);
|
||||||
|
}
|
||||||
|
if (value.length > 8) {
|
||||||
|
formattedValue += '-' + value.substring(8, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = formattedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendToBackend = async (data: FormData): Promise<boolean> => {
|
||||||
|
try{
|
||||||
|
const response = await client.post<{success: boolean}>("/email/", {
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data.success;
|
||||||
|
}catch (error: any){
|
||||||
|
console.error("Error sending request:", error);
|
||||||
|
|
||||||
|
throw new Error("Не удалось отправить заявку. Попробуйте позже.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
await sendToBackend(data);
|
||||||
|
|
||||||
|
Modal.success({
|
||||||
|
title: "Прайс-лист отправлен!",
|
||||||
|
okText: "Хорошо",
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
setIsOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
Modal.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
content: error instanceof Error ? error.message : "Произошла неизвестная ошибка",
|
||||||
|
okText: "Понятно",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modalka open={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
footer={null}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered
|
||||||
|
width={620}
|
||||||
|
closable={!isSubmitting}
|
||||||
|
maskClosable={!isSubmitting}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
className={styles.modalBox}
|
||||||
|
>
|
||||||
|
<h2 className={styles.title}>СКАЧАЙТЕ ПРАЙС_ЛИСТ</h2>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Заполните форму ниже, и прайс-лист будт выслан вам на почту
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="Введите ФИО *"
|
||||||
|
{...register("name")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errors.name && <span className={styles.error}>{errors.name.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="Введите email *"
|
||||||
|
{...register("email")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errors.email && <span className={styles.error}>{errors.email.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="+7 (___) ___-__-__ *"
|
||||||
|
{...register("phone")}
|
||||||
|
onInput={handlePhoneInput}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errors.phone && <span className={styles.error}>{errors.phone.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={isSubmitting ? "ОТПРАВКА..." : "СКАЧАТЬ ПРАЙС-ЛИСТ"}
|
||||||
|
className={styles.button}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriceLisForm;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 72px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
|
||||||
|
gap: 42px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 42px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #f7fbff 0%, #ffffff 58%, #fff7cf 100%);
|
||||||
|
box-shadow: 0 18px 50px rgba(17, 31, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
color: #111111;
|
||||||
|
font-size: clamp(30px, 4vw, 46px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 18px 0 0;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneBlock {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneIcon {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff2a6;
|
||||||
|
color: #111111;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneNumber {
|
||||||
|
color: #111111;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneInfo {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #596474;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formLabel {
|
||||||
|
margin: 28px 0 20px;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.inner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.wrapper {
|
||||||
|
margin-top: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner,
|
||||||
|
.right {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text,
|
||||||
|
.formLabel {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneBlock {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneNumber {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { PhoneOutlined } from "@ant-design/icons";
|
||||||
|
import styles from "./QuestionsBlock.module.css";
|
||||||
|
import Button from "../Button/Button";
|
||||||
|
import ModalRequestFormQuastion from "../../features/MainPage/PhoneRequest";
|
||||||
|
|
||||||
|
const QuestionsBlock: React.FC = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.wrapper}>
|
||||||
|
<div className={styles.inner}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<span className={styles.kicker}>Связь с нами</span>
|
||||||
|
<h2 className={styles.title}>Есть вопросы?</h2>
|
||||||
|
|
||||||
|
<p className={styles.text}>
|
||||||
|
Мы готовы ответить на вопросы, помочь с подбором продукции и
|
||||||
|
предоставить информацию о сотрудничестве.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.right}>
|
||||||
|
<div className={styles.phoneBlock}>
|
||||||
|
<PhoneOutlined className={styles.phoneIcon} />
|
||||||
|
<div>
|
||||||
|
<a href="tel:+79101210962" className={styles.phoneNumber}>
|
||||||
|
+7 831 410-90-89
|
||||||
|
</a>
|
||||||
|
<div className={styles.phoneInfo}>
|
||||||
|
Понедельник - пятница, с 09:00 до 18:00 по Москве
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.formLabel}>
|
||||||
|
Или оставьте заявку, и мы вам перезвоним.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button label="Задать вопрос" onClick={() => setOpen(true)} />
|
||||||
|
<ModalRequestFormQuastion open={open} onClose={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionsBlock;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { CategoryAtributes } from "../../types/categories";
|
||||||
|
import styles from "./Table.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
attributes: Record<string, any>;
|
||||||
|
attrList: CategoryAtributes[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductAttributes: React.FC<Props> = ({ attributes, attrList }) => {
|
||||||
|
const rows = Object.entries(attributes)
|
||||||
|
.map(([key, val]) => {
|
||||||
|
const foundAttr = attrList.find((attr) => key === attr.flag);
|
||||||
|
let newKey: string;
|
||||||
|
|
||||||
|
if (key === "amount") {
|
||||||
|
newKey = "Количество";
|
||||||
|
} else if (key === "size") {
|
||||||
|
newKey = "Размер";
|
||||||
|
} else {
|
||||||
|
newKey = foundAttr ? foundAttr.name : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [newKey, val];
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
([newKey, value]) =>
|
||||||
|
newKey !== "categoryId" &&
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
String(value).trim() !== ""
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
{rows.map(([key, value]) => (
|
||||||
|
<div key={`${key}-${value}`} className={styles.row}>
|
||||||
|
<span className={styles.name}>{key}</span>
|
||||||
|
<span className={styles.dots} />
|
||||||
|
<span className={styles.value}>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductAttributes;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid #edf0f4;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: #596474;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px dotted #c9d0da;
|
||||||
|
margin: 0 10px;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #151515;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .name {
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .dots {
|
||||||
|
border-color: #ffd400;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.form {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18px 24px;
|
||||||
|
border-radius: 50px;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: green;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Modal } from "antd";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import InputMask from "react-input-mask";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as yup from "yup";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import styles from "../../components/ModalRequestForm/ModalRequestForm.module.css";
|
||||||
|
import Button from "../../components/Button/Button";
|
||||||
|
import { client } from "../../api/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import Modalka from "../../components/Modal/Modal";
|
||||||
|
|
||||||
|
// ✅ Схема валидации
|
||||||
|
const schema = yup.object().shape({
|
||||||
|
name: yup.string().required("Введите имя"),
|
||||||
|
email: yup.string().email("Некорректный Email").required("Введите Email"),
|
||||||
|
phone: yup.string().min(18, "Введите корректный номер").required("Введите номер"),
|
||||||
|
message: yup.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
message?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalRequestFormQuastion: React.FC<Props> = ({ open, onClose }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
console.log('ModalRequestForm render, open:', open);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
reset,
|
||||||
|
} = useForm({ resolver: yupResolver(schema) });
|
||||||
|
|
||||||
|
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let value = e.target.value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (value.startsWith('7') || value.startsWith('8')) {
|
||||||
|
value = value.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedValue = '+7 ';
|
||||||
|
|
||||||
|
if (value.length > 0) {
|
||||||
|
formattedValue += '(' + value.substring(0, 3);
|
||||||
|
}
|
||||||
|
if (value.length > 3) {
|
||||||
|
formattedValue += ') ' + value.substring(3, 6);
|
||||||
|
}
|
||||||
|
if (value.length > 6) {
|
||||||
|
formattedValue += '-' + value.substring(6, 8);
|
||||||
|
}
|
||||||
|
if (value.length > 8) {
|
||||||
|
formattedValue += '-' + value.substring(8, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = formattedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Отправка данных на бэкенд
|
||||||
|
const sendToBackend = async (data: FormData): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await client.post<{ success: boolean }>("/email/", payload);
|
||||||
|
|
||||||
|
return response.data.success;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error sending request:", error);
|
||||||
|
throw new Error("Не удалось отправить заявку. Попробуйте позже.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Submit
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
await sendToBackend(data);
|
||||||
|
|
||||||
|
// Показываем уведомление об успехе
|
||||||
|
Modal.success({
|
||||||
|
title: "Заявка отправлена!",
|
||||||
|
content: "Наш менеджер свяжется с вами в ближайшее время.",
|
||||||
|
okText: "Хорошо",
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
setIsOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
// Показываем ошибку
|
||||||
|
Modal.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
content: error instanceof Error ? error.message : "Произошла неизвестная ошибка",
|
||||||
|
okText: "Понятно",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modalka open={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
footer={null}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered
|
||||||
|
width={620}
|
||||||
|
closable={!isSubmitting}
|
||||||
|
maskClosable={!isSubmitting}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
className={styles.modalBox}
|
||||||
|
>
|
||||||
|
<h2 className={styles.title}>Задайте вопрос</h2>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Ожидайте ответа на указанную почту
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="Введите имя *"
|
||||||
|
{...register("name")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errors.name && <span className={styles.error}>{errors.name.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="Введите email *"
|
||||||
|
{...register("email")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errors.email && <span className={styles.error}>{errors.email.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*<div className={styles.field}>
|
||||||
|
<InputMask
|
||||||
|
mask="+7 (999) 999-99-99"
|
||||||
|
{...register("phone")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{(inputProps: any) => (
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="Введите № телефона *"
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</InputMask>
|
||||||
|
{errors.phone && <span className={styles.error}>{errors.phone.message}</span>}
|
||||||
|
</div>*/}
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="+7 (___) ___-__-__ *"
|
||||||
|
{...register("phone")}
|
||||||
|
onInput={handlePhoneInput}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{errors.phone && <span className={styles.error}>{errors.phone.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className={styles.textarea}
|
||||||
|
placeholder="Ваш вопрос"
|
||||||
|
{...register("message")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={isSubmitting ? "ОТПРАВКА..." : "ОТПРАВИТЬ"}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalRequestFormQuastion;
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
.catalogWrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productsSection {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 260px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: rgba(255, 212, 0, 0.75);
|
||||||
|
box-shadow: 0 18px 45px rgba(17, 31, 51, 0.08);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(135deg, rgba(247, 251, 255, 0.9), transparent 42%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryImg,
|
||||||
|
.img {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 180px;
|
||||||
|
height: 150px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryCard {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryImageFrame {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryImageFrame .img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryCard:hover .img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryCard .name {
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productCard {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productImageFrame {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productImage {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productCard:hover .productImage {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productCard .cardContent {
|
||||||
|
min-height: 86px;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name,
|
||||||
|
.categoryName {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: #202733;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffd400;
|
||||||
|
color: #111111;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .plus {
|
||||||
|
background: #ffe667;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #151515;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
margin: 0;
|
||||||
|
color: #596474;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #596474;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
border-color: #e7ebf0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeCat {
|
||||||
|
border-color: rgba(255, 212, 0, 0.85);
|
||||||
|
background: #fff7cf;
|
||||||
|
color: #111111;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtersWrap {
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #e1e6ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocks {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockTitle {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #202733;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
color: #596474;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #ffd400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply,
|
||||||
|
.reset,
|
||||||
|
.go,
|
||||||
|
.page {
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply {
|
||||||
|
padding: 0 22px;
|
||||||
|
border: none;
|
||||||
|
background: #ffd400;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset,
|
||||||
|
.go,
|
||||||
|
.page {
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid #d7dce3;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #151515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply:hover,
|
||||||
|
.go:hover,
|
||||||
|
.page:hover {
|
||||||
|
background: #ffe667;
|
||||||
|
border-color: #ffe667;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset:hover {
|
||||||
|
border-color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: #ffd400;
|
||||||
|
background: #ffd400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1090px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.grid {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
min-height: 230px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryImg,
|
||||||
|
.img {
|
||||||
|
height: 128px;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryCard {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryImageFrame .img {
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryCard .name {
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productCard {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productCard .cardContent {
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name,
|
||||||
|
.categoryName {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply,
|
||||||
|
.reset {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "./Catalog.module.css";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
|
import { setSelectedCategory } from "./categoriesSlice";
|
||||||
|
import { fetchProductsByCategory, setFilters, setPage } from "./productsSlice";
|
||||||
|
|
||||||
|
|
||||||
|
const CategoryCards: React.FC = () => {
|
||||||
|
const categories = useAppSelector(s => s.categories.items);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onSelect = (catId:number) => {
|
||||||
|
dispatch(setSelectedCategory(catId));
|
||||||
|
dispatch(setFilters({}));
|
||||||
|
dispatch(setPage(1));
|
||||||
|
dispatch(fetchProductsByCategory({ categoryId: catId, page: 1, limit: 9 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{categories.map(c => (
|
||||||
|
<div key={c.id} className={`${styles.card} ${styles.categoryCard}`} onClick={() => onSelect(c.id)}>
|
||||||
|
<div className={styles.categoryImageFrame}>
|
||||||
|
<img
|
||||||
|
src={c.image || "/mnt/data/c9552f5e-1741-4845-8d5f-9d438f068636.png"}
|
||||||
|
alt={c.name}
|
||||||
|
className={styles.img}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.name}>{c.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default CategoryCards;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../app/hooks";
|
||||||
|
import styles from "./Catalog.module.css";
|
||||||
|
import { setFilters, fetchProductsByCategory, setPage } from "./productsSlice";
|
||||||
|
|
||||||
|
export const CategoryFilters = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { items: cats, selectedCategoryId } = useAppSelector((s) => s.categories);
|
||||||
|
const products = useAppSelector((s) => s.products);
|
||||||
|
const cat = cats.find((x) => x.id === selectedCategoryId);
|
||||||
|
const [local, setLocal] = useState<Record<string, (string | number)[]>>({});
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
dispatch(setFilters(local));
|
||||||
|
dispatch(setPage(1));
|
||||||
|
if (selectedCategoryId) {
|
||||||
|
dispatch(
|
||||||
|
fetchProductsByCategory({
|
||||||
|
categoryId: selectedCategoryId,
|
||||||
|
page: 1,
|
||||||
|
limit: products.limit,
|
||||||
|
filters: local,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setLocal({});
|
||||||
|
dispatch(setFilters({}));
|
||||||
|
dispatch(setPage(1));
|
||||||
|
if (selectedCategoryId) {
|
||||||
|
dispatch(
|
||||||
|
fetchProductsByCategory({
|
||||||
|
categoryId: selectedCategoryId,
|
||||||
|
page: 1,
|
||||||
|
limit: products.limit,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cat) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.filtersWrap}>
|
||||||
|
<h4 className={styles.title}>Параметры</h4>
|
||||||
|
<div className={styles.blocks}>
|
||||||
|
{cat.attributes.map((attr) => (
|
||||||
|
<div key={attr.id} className={styles.block}>
|
||||||
|
<div className={styles.blockTitle}>{attr.name}</div>
|
||||||
|
<div className={styles.options}>
|
||||||
|
{attr.vals.map((v) => {
|
||||||
|
const key = `${attr.flag}`;
|
||||||
|
const active = local[key]?.includes(v.val);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={v.id} className={styles.opt}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!active}
|
||||||
|
onChange={() => {
|
||||||
|
const cur = local[key] ?? [];
|
||||||
|
const next = cur.includes(v.val)
|
||||||
|
? cur.filter((x) => x !== v.val)
|
||||||
|
: [...cur, v.val];
|
||||||
|
setLocal({ ...local, [key]: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{v.val}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button className={styles.apply} onClick={apply}>
|
||||||
|
Показать
|
||||||
|
</button>
|
||||||
|
<button className={styles.reset} onClick={reset}>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryFilters;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "./Catalog.module.css";
|
||||||
|
import { useAppSelector, useAppDispatch } from "../../app/hooks";
|
||||||
|
import { setPage, fetchProductsByCategory } from "./productsSlice";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
categoryId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Pagination: React.FC<Props> = ({ categoryId }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { total, page, limit } = useAppSelector((s) => s.products);
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const go = (p: number) => {
|
||||||
|
dispatch(setPage(p));
|
||||||
|
dispatch(fetchProductsByCategory({ categoryId, page: p, limit }));
|
||||||
|
window.scrollTo({ top: 200, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<button className={styles.go} onClick={() => go(1)} disabled={page === 1}>
|
||||||
|
В начало
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.pages}>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
className={`${styles.page} ${p === page ? styles.active : ""}`}
|
||||||
|
onClick={() => go(p)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.go}
|
||||||
|
onClick={() => go(totalPages)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
В конец
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pagination;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Product } from "../../types/product";
|
||||||
|
import styles from "./Catalog.module.css";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
product: Product;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductCard: React.FC<Props> = ({ product }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onSelect = () => {
|
||||||
|
navigate(`/product/${product.slug}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.card} ${styles.productCard}`}
|
||||||
|
onClick={() => onSelect()}
|
||||||
|
>
|
||||||
|
<div className={styles.productImageFrame}>
|
||||||
|
<img
|
||||||
|
className={styles.productImage}
|
||||||
|
src={product.images[0]}
|
||||||
|
alt={product.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<p className={styles.name}>{product.name}</p>
|
||||||
|
<button className={styles.plus}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCard;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import styles from "./Catalog.module.css";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
|
import { fetchCategories, setSelectedCategory } from "./categoriesSlice";
|
||||||
|
import { fetchProductsByCategory, setFilters, setPage } from "./productsSlice";
|
||||||
|
|
||||||
|
const SidebarCategories: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { items, selectedCategoryId, loading } = useAppSelector((s) => s.categories);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchCategories());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const onClick = (catId: number) => {
|
||||||
|
dispatch(setSelectedCategory(catId));
|
||||||
|
dispatch(setFilters({}));
|
||||||
|
dispatch(setPage(1));
|
||||||
|
dispatch(fetchProductsByCategory({ categoryId: catId, page: 1, limit: 9 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<h3 className={styles.title}>Категории товаров</h3>
|
||||||
|
{loading ? (
|
||||||
|
<p className={styles.loading}>Загрузка...</p>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{items.map((cat) => (
|
||||||
|
<li
|
||||||
|
key={cat.id}
|
||||||
|
className={`${styles.item} ${
|
||||||
|
selectedCategoryId === cat.id ? styles.activeCat : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onClick(cat.id)}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarCategories;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
import type { Category } from "../../types/categories";
|
import type { Category } from "../../types/categories";
|
||||||
import axios from "axios";
|
import {client} from "../../api/client";
|
||||||
|
|
||||||
export const fetchCategories = createAsyncThunk<Category[]>(
|
export const fetchCategories = createAsyncThunk<Category[]>(
|
||||||
"categories/fetchCategories",
|
"categories/fetchCategories",
|
||||||
async () => {
|
async () => {
|
||||||
const res = await axios.get("/api/categories");
|
const res = await client.get<Category[]>("/categories/");
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
||||||
import type {PayloadAction} from "@reduxjs/toolkit";
|
import type {PayloadAction} from "@reduxjs/toolkit";
|
||||||
import client from "../../api/client";
|
import {client} from "../../api/client";
|
||||||
import type { Product } from "../../types/product";
|
import type { Product } from "../../types/product";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
interface FetchParams {
|
interface FetchParams {
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
@@ -11,6 +10,13 @@ interface FetchParams {
|
|||||||
filters?: Record<string, string | number | (string | number)[]>;
|
filters?: Record<string, string | number | (string | number)[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FetchResponce{
|
||||||
|
items: Product[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchProductsByCategory = createAsyncThunk(
|
export const fetchProductsByCategory = createAsyncThunk(
|
||||||
"products/fetchByCategory",
|
"products/fetchByCategory",
|
||||||
async (params: FetchParams) => {
|
async (params: FetchParams) => {
|
||||||
@@ -26,12 +32,20 @@ export const fetchProductsByCategory = createAsyncThunk(
|
|||||||
else qp.append(k, String(v));
|
else qp.append(k, String(v));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const res = await client.get(`/products?${qp.toString()}`);
|
const res = await client.get<FetchResponce>(`/products?${qp.toString()}/`);
|
||||||
// ожидаем { items: Product[], total: number, page: number, limit: number }
|
// ожидаем { items: Product[], total: number, page: number, limit: number }
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fetchProductBySlug = createAsyncThunk(
|
||||||
|
"product/fetchBySlug",
|
||||||
|
async (slug: string) => {
|
||||||
|
const response = await client.get<Product>(`/products/${slug}/`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
interface ProductsState {
|
interface ProductsState {
|
||||||
items: Product[];
|
items: Product[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -40,6 +54,8 @@ interface ProductsState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
filtersApplied: Record<string, (string|number)[]>;
|
filtersApplied: Record<string, (string|number)[]>;
|
||||||
|
selectedProduct: Product | null;
|
||||||
|
//selectedProductId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ProductsState = {
|
const initialState: ProductsState = {
|
||||||
@@ -50,12 +66,17 @@ const initialState: ProductsState = {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
filtersApplied: {},
|
filtersApplied: {},
|
||||||
|
selectedProduct: null
|
||||||
|
//selectedProductId: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const slice = createSlice({
|
const slice = createSlice({
|
||||||
name: "products",
|
name: "products",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
/*setSelectedProducts(state, action: PayloadAction<number>){
|
||||||
|
state.selectedProductId = action.payload;
|
||||||
|
},*/
|
||||||
setPage(state, action: PayloadAction<number>) {
|
setPage(state, action: PayloadAction<number>) {
|
||||||
state.page = action.payload;
|
state.page = action.payload;
|
||||||
},
|
},
|
||||||
@@ -65,10 +86,14 @@ const slice = createSlice({
|
|||||||
setFilters(state, action: PayloadAction<ProductsState["filtersApplied"]>) {
|
setFilters(state, action: PayloadAction<ProductsState["filtersApplied"]>) {
|
||||||
state.filtersApplied = action.payload;
|
state.filtersApplied = action.payload;
|
||||||
},
|
},
|
||||||
|
setSelectedProduct(state, action: PayloadAction<Product | null>) {
|
||||||
|
state.selectedProduct = action.payload;
|
||||||
|
},
|
||||||
resetProducts(state) {
|
resetProducts(state) {
|
||||||
state.items = [];
|
state.items = [];
|
||||||
state.total = 0;
|
state.total = 0;
|
||||||
state.page = 1;
|
state.page = 1;
|
||||||
|
state.selectedProduct = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
@@ -87,6 +112,19 @@ const slice = createSlice({
|
|||||||
.addCase(fetchProductsByCategory.rejected, (s, a) => {
|
.addCase(fetchProductsByCategory.rejected, (s, a) => {
|
||||||
s.loading = false;
|
s.loading = false;
|
||||||
s.error = a.error.message ?? "Ошибка";
|
s.error = a.error.message ?? "Ошибка";
|
||||||
|
})
|
||||||
|
.addCase(fetchProductBySlug.pending, (s) => {
|
||||||
|
s.loading = true;
|
||||||
|
s.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchProductBySlug.fulfilled, (s, a) => {
|
||||||
|
s.loading = false;
|
||||||
|
s.selectedProduct = a.payload;
|
||||||
|
s.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchProductBySlug.rejected, (s, a) => {
|
||||||
|
s.loading = false;
|
||||||
|
s.error = a.error.message ?? "Ошибка";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
background-color: #242424;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
@@ -37,6 +37,7 @@ body {
|
|||||||
place-items: center;*/
|
place-items: center;*/
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-family: "Inter", "Roboto", "Arial", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*h1 {
|
/*h1 {
|
||||||
@@ -75,3 +76,4 @@ button:focus-visible {
|
|||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,12 +5,15 @@ import { BrowserRouter } from 'react-router'
|
|||||||
import AppRoutes from './app/routes.tsx'
|
import AppRoutes from './app/routes.tsx'
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { store } from "./app/store";
|
import { store } from "./app/store";
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<HelmetProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</HelmetProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
color: #151515;
|
||||||
|
padding: 126px 20px 96px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.facts,
|
||||||
|
.story,
|
||||||
|
.principles,
|
||||||
|
.production {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.92fr);
|
||||||
|
gap: 52px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroContent {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker,
|
||||||
|
.sectionLabel {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
max-width: 760px;
|
||||||
|
font-size: clamp(38px, 4vw, 68px);
|
||||||
|
line-height: 1.05;
|
||||||
|
margin: 0;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
max-width: 650px;
|
||||||
|
margin: 28px 0 0;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #3f4652;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryLink,
|
||||||
|
.secondaryLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryLink {
|
||||||
|
background: #ffd400;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryLink:hover {
|
||||||
|
background: #ffe667;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryLink {
|
||||||
|
border: 1px solid #d7dce3;
|
||||||
|
color: #111;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryLink:hover {
|
||||||
|
border-color: var(--lightblue);
|
||||||
|
color: var(--darkblue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroImageWrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 520px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #edf1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 520px;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact {
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--darkblue);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact span {
|
||||||
|
color: #4f5966;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.8fr 1.2fr;
|
||||||
|
gap: 60px;
|
||||||
|
margin-top: 92px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story h2,
|
||||||
|
.production h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #151515;
|
||||||
|
font-size: clamp(30px, 4vw, 46px);
|
||||||
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyText {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyText p,
|
||||||
|
.productionText p {
|
||||||
|
margin: 0;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 22px;
|
||||||
|
margin-top: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle {
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 50px rgba(17, 31, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.principleIcon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--darkblue);
|
||||||
|
background: #eef7ff;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #151515;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.principle p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4f5966;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.production {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.8fr);
|
||||||
|
gap: 48px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 96px;
|
||||||
|
padding: 46px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #f7fbff 0%, #ffffff 58%, #fff7cf 100%);
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productionText p {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkList {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkList li {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
color: #202733;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkList svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 20px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page {
|
||||||
|
padding-top: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.story,
|
||||||
|
.production {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroImageWrap,
|
||||||
|
.heroImage {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts,
|
||||||
|
.principles {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
gap: 28px;
|
||||||
|
margin-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.production {
|
||||||
|
padding: 34px 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.page {
|
||||||
|
padding: 92px 16px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
gap: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p,
|
||||||
|
.storyText p,
|
||||||
|
.productionText p {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroActions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryLink,
|
||||||
|
.secondaryLink {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroImageWrap,
|
||||||
|
.heroImage {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact,
|
||||||
|
.principle {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.production {
|
||||||
|
margin-top: 72px;
|
||||||
|
padding: 28px 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import styles from "./About.module.css";
|
||||||
|
import SEO from "../../shared/ui/SEO";
|
||||||
|
import productionImage from "../../assets/images/photo_2026-06-22_23-17-23.jpg";
|
||||||
|
|
||||||
|
const facts = [
|
||||||
|
{ value: "Собственное", label: "производство и техническая база" },
|
||||||
|
{ value: "Контроль", label: "сырья, геометрии и качества изделий" },
|
||||||
|
{ value: "Партнерство", label: "с монтажными, торговыми и строительными компаниями" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const principles = [
|
||||||
|
{
|
||||||
|
icon: <SafetyCertificateOutlined />,
|
||||||
|
title: "Надежность",
|
||||||
|
text: "Выпускаем продукцию для инженерных систем, где важны стабильные характеристики, аккуратная геометрия и понятная комплектация.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ExperimentOutlined />,
|
||||||
|
title: "Контроль качества",
|
||||||
|
text: "Следим за производственными этапами, проверяем параметры изделий и бережно относимся к повторяемости каждой партии.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
title: "Гибкость",
|
||||||
|
text: "Помогаем подобрать подходящие позиции под объект, объем закупки и требования проекта, включая контрактное производство.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const About = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SEO
|
||||||
|
title="О компании"
|
||||||
|
description="Информация о производственной компании: выпуск ПВХ-продукции, контроль качества, контрактное производство и сотрудничество с партнерами."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className={styles.page}>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div className={styles.heroContent}>
|
||||||
|
<span className={styles.kicker}>О компании</span>
|
||||||
|
<h1>Производим решения для инженерных и строительных задач</h1>
|
||||||
|
<p>
|
||||||
|
Мы развиваем современное производство ПВХ-продукции и комплектующих
|
||||||
|
для объектов, где важны надежность поставок, понятное качество и
|
||||||
|
внимательная работа с заказчиком.
|
||||||
|
</p>
|
||||||
|
<div className={styles.heroActions}>
|
||||||
|
<Link to="/products" className={styles.primaryLink}>
|
||||||
|
Смотреть продукцию
|
||||||
|
</Link>
|
||||||
|
<a href="tel:+79101210962" className={styles.secondaryLink}>
|
||||||
|
+7 831 410-90-89
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.heroImageWrap}>
|
||||||
|
<img
|
||||||
|
src={productionImage}
|
||||||
|
alt="Производственная площадка компании"
|
||||||
|
className={styles.heroImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.facts} aria-label="Ключевые факты">
|
||||||
|
{facts.map((fact) => (
|
||||||
|
<div className={styles.fact} key={fact.value}>
|
||||||
|
<strong>{fact.value}</strong>
|
||||||
|
<span>{fact.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.story}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.sectionLabel}>Кто мы</span>
|
||||||
|
<h2>Компания, которая держит фокус на производстве и результате</h2>
|
||||||
|
</div>
|
||||||
|
<div className={styles.storyText}>
|
||||||
|
<p>
|
||||||
|
Наша команда работает с продукцией для водоснабжения, канализации,
|
||||||
|
монтажа и смежных инженерных направлений. Мы понимаем, что для
|
||||||
|
клиентов важны не только цена и наличие, но и предсказуемость:
|
||||||
|
сроки, качество партии, корректная консультация и спокойная
|
||||||
|
коммуникация на каждом этапе заказа.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Поэтому мы выстраиваем процессы вокруг практических задач клиента:
|
||||||
|
помогаем с подбором номенклатуры, готовим предложения под объем,
|
||||||
|
поддерживаем партнеров в регулярных закупках и берем в работу
|
||||||
|
запросы на контрактное производство.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.principles}>
|
||||||
|
{principles.map((item) => (
|
||||||
|
<article className={styles.principle} key={item.title}>
|
||||||
|
<div className={styles.principleIcon}>{item.icon}</div>
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
<p>{item.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.production}>
|
||||||
|
<div className={styles.productionText}>
|
||||||
|
<span className={styles.sectionLabel}>Производство</span>
|
||||||
|
<h2>От сырья до готовой партии</h2>
|
||||||
|
<p>
|
||||||
|
Мы уделяем внимание стабильности технологического процесса,
|
||||||
|
состоянию оборудования и качеству готовых изделий. Такой подход
|
||||||
|
помогает сохранять повторяемость продукции и уверенно работать как
|
||||||
|
с разовыми заказами, так и с постоянными поставками.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className={styles.checkList}>
|
||||||
|
<li>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
Подбираем материалы под назначение продукции
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
Контролируем размеры и внешний вид изделий
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
Упаковываем продукцию с учетом хранения и доставки
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
Работаем с оптовыми, проектными и партнерскими заказами
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default About;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
.page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 70vh;
|
||||||
|
padding: 126px 20px 96px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #151515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.mapBlock {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr);
|
||||||
|
gap: 56px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroText {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0;
|
||||||
|
color: #111111;
|
||||||
|
font-size: clamp(38px, 4vw, 64px);
|
||||||
|
line-height: 1.06;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 26px 0 0;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactPanel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #f7fbff 0%, #ffffff 62%, #fff7cf 100%);
|
||||||
|
box-shadow: 0 18px 50px rgba(17, 31, 51, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff2a6;
|
||||||
|
color: #111111;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #77808c;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactItem p {
|
||||||
|
margin: 0;
|
||||||
|
color: #202733;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactItem a {
|
||||||
|
color: #151515;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactItem a:hover {
|
||||||
|
color: var(--darkblue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapBlock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 42px;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
min-width: 0;
|
||||||
|
color: #202733;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder strong,
|
||||||
|
.mapPlaceholder span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder strong {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #596474;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: #ffd400;
|
||||||
|
color: #111111;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapLink:hover {
|
||||||
|
background: #ffe667;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page {
|
||||||
|
padding-top: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapBlock {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.page {
|
||||||
|
padding: 92px 16px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactPanel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactItem {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapBlock {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapPlaceholder {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapLink {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
EnvironmentOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
PhoneOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import SEO from "../../shared/ui/SEO";
|
||||||
|
import styles from "./Contacts.module.css";
|
||||||
|
|
||||||
|
const phones = [
|
||||||
|
{
|
||||||
|
label: "+7 831 410-90-89",
|
||||||
|
href: "tel:+78314109089",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Contacts = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SEO
|
||||||
|
title="Контакты"
|
||||||
|
description="Контакты RK-Plastic Tech: адрес производства и склада в Дзержинске, телефоны и электронная почта для связи."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className={styles.page}>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div className={styles.heroText}>
|
||||||
|
<span className={styles.kicker}>Контакты</span>
|
||||||
|
<h1>Свяжитесь с нами по вопросам продукции и поставок</h1>
|
||||||
|
<p>
|
||||||
|
Подскажем по ассортименту, наличию, срокам производства и условиям
|
||||||
|
сотрудничества.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.contactPanel}>
|
||||||
|
<article className={styles.contactItem}>
|
||||||
|
<div className={styles.icon}>
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>Адрес производства и склада</span>
|
||||||
|
<p>
|
||||||
|
Нижегородская область, г. Дзержинск, Автозаводское шоссе, 70А
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className={styles.contactItem}>
|
||||||
|
<div className={styles.icon}>
|
||||||
|
<PhoneOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>Телефоны</span>
|
||||||
|
<div className={styles.links}>
|
||||||
|
{phones.map((phone) => (
|
||||||
|
<a href={phone.href} key={phone.href}>
|
||||||
|
{phone.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className={styles.contactItem}>
|
||||||
|
<div className={styles.icon}>
|
||||||
|
<MailOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>Почта</span>
|
||||||
|
<a href="mailto:rkplastic@mail.ru">rkplastic@mail.ru</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.mapBlock} aria-label="Адрес на карте">
|
||||||
|
<div className={styles.mapPlaceholder}>
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
<div>
|
||||||
|
<strong>Дзержинск, Автозаводское шоссе, 70А</strong>
|
||||||
|
<span>Производство и склад RK-Plastic Tech</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className={styles.mapLink}
|
||||||
|
href="https://yandex.ru/maps/?text=%D0%9D%D0%B8%D0%B6%D0%B5%D0%B3%D0%BE%D1%80%D0%BE%D0%B4%D1%81%D0%BA%D0%B0%D1%8F%20%D0%BE%D0%B1%D0%BB%D0%B0%D1%81%D1%82%D1%8C%2C%20%D0%B3.%20%D0%94%D0%B7%D0%B5%D1%80%D0%B6%D0%B8%D0%BD%D1%81%D0%BA%2C%20%D0%90%D0%B2%D1%82%D0%BE%D0%B7%D0%B0%D0%B2%D0%BE%D0%B4%D1%81%D0%BA%D0%BE%D0%B5%20%D1%88%D0%BE%D1%81%D1%81%D0%B5%2C%2070%D0%90"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Открыть в Яндекс Картах
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Contacts;
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 126px 20px 96px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #151515;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
min-height: 60vh;
|
||||||
|
padding-top: 140px;
|
||||||
|
color: #596474;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top,
|
||||||
|
.hero,
|
||||||
|
.details {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid #d7dce3;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #151515;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
border-color: #111111;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 30px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right,
|
||||||
|
.details {
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 520px;
|
||||||
|
padding: 46px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 430px;
|
||||||
|
max-height: 430px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
background: linear-gradient(135deg, #f7fbff 0%, #ffffff 58%, #fff7cf 100%);
|
||||||
|
box-shadow: 0 18px 50px rgba(17, 31, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #77808c;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a {
|
||||||
|
color: #596474;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs a:hover {
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--lightblue);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0;
|
||||||
|
color: #111111;
|
||||||
|
font-size: clamp(32px, 3vw, 54px);
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 24px 0 0;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 28px 0 24px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background: #fff7cf;
|
||||||
|
color: #111111;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 30px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid #e7ebf0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 0 30px;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #e7ebf0;
|
||||||
|
background: transparent;
|
||||||
|
color: #596474;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs .active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: #151515;
|
||||||
|
font-size: clamp(26px, 3vw, 36px);
|
||||||
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #3f4652;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page {
|
||||||
|
padding-top: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.page {
|
||||||
|
padding: 92px 16px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right,
|
||||||
|
.content {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left img {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short,
|
||||||
|
.content p {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e7ebf0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
|
import { fetchProductBySlug } from "../../features/catalog/productsSlice";
|
||||||
|
import styles from "./ItemPage.module.css";
|
||||||
|
import Button from "../../components/Button/Button";
|
||||||
|
import PriceLisForm from "../../components/PriceLisForm/PriceLisForm";
|
||||||
|
import ProductAttributes from "../../components/Table/ProducTable";
|
||||||
|
import Link from "antd/es/typography/Link";
|
||||||
|
import { setSelectedCategory } from "../../features/catalog/categoriesSlice";
|
||||||
|
|
||||||
|
const ItemPage = () => {
|
||||||
|
const { slug } = useParams();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { loading } = useAppSelector((state) => state.products);
|
||||||
|
const { items: cats, selectedCategoryId } = useAppSelector((s) => s.categories);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<"desc" | "chars">("desc");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (slug) dispatch(fetchProductBySlug(slug));
|
||||||
|
}, [dispatch, slug]);
|
||||||
|
|
||||||
|
const product = useAppSelector((state) => state.products.selectedProduct);
|
||||||
|
const category = cats.find((cat) => cat.id === Number(selectedCategoryId));
|
||||||
|
|
||||||
|
if (loading || !product) {
|
||||||
|
return <div className={styles.loading}>Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return <div className={styles.loading}>Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.page}>
|
||||||
|
<PriceLisForm open={open} onClose={() => setOpen(false)} />
|
||||||
|
|
||||||
|
<div className={styles.top}>
|
||||||
|
<button className={styles.back} onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
Назад в каталог
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<img src={product.images[0]} alt={product.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.right}>
|
||||||
|
<div className={styles.breadcrumbs}>
|
||||||
|
<Link
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/products");
|
||||||
|
dispatch(setSelectedCategory(null));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Категории
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link onClick={() => navigate("/products")}>{category.name}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{product.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.kicker}>Карточка товара</span>
|
||||||
|
<h1 className={styles.title}>{product.name}</h1>
|
||||||
|
<p className={styles.short}>{product.description}</p>
|
||||||
|
|
||||||
|
<div className={styles.status}>
|
||||||
|
{product.inStock ? "В наличии" : "Под заказ"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button label="Скачать прайс-лист" onClick={() => setOpen(true)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.details}>
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
<button
|
||||||
|
className={activeTab === "desc" ? styles.active : ""}
|
||||||
|
onClick={() => setActiveTab("desc")}
|
||||||
|
>
|
||||||
|
Описание
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeTab === "chars" ? styles.active : ""}
|
||||||
|
onClick={() => setActiveTab("chars")}
|
||||||
|
>
|
||||||
|
Характеристики
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
{activeTab === "desc" && (
|
||||||
|
<div>
|
||||||
|
<h2>Описание</h2>
|
||||||
|
<p>{product.description}</p>
|
||||||
|
<p>{product.full_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "chars" && (
|
||||||
|
<div>
|
||||||
|
<h2>Характеристики</h2>
|
||||||
|
<ProductAttributes
|
||||||
|
attributes={product.attributes}
|
||||||
|
attrList={category.attributes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemPage;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 72px 20px 96px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.container {
|
||||||
|
padding: 52px 16px 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Row, Col } from 'antd';
|
|
||||||
import { RocketOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
|
||||||
import styles from './Main.module.css'
|
import styles from './Main.module.css'
|
||||||
import BannerSlider from '../../components/Banner/Banner';
|
import BannerSlider from '../../components/Banner/Banner';
|
||||||
import CategoriesGrid from '../../components/Categories/Categories';
|
import CategoriesGrid from '../../components/Categories/Categories';
|
||||||
@@ -14,7 +12,7 @@ const Main: React.FC = () =>{
|
|||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<CategoriesGrid/>
|
<CategoriesGrid/>
|
||||||
{<ProductionBanner/>}
|
<ProductionBanner/>
|
||||||
<QuestionsBlock/>
|
<QuestionsBlock/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||