This commit is contained in:
sbb45
2026-06-02 17:45:29 +05:00
commit 475c7cc73c
155 changed files with 15164 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
Generated
+1
View File
@@ -0,0 +1 @@
package.json
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="test@localhost" uuid="1c7ebe9a-5486-490c-90e2-c7bef0852e7a">
<driver-ref>mysql_aurora.aws_wrapper</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>software.amazon.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:aws-wrapper:mysql://localhost:3306/test</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="test@localhost [2]" uuid="74ea22b6-ae49-4195-a2dc-433e8abb1934">
<driver-ref>mysql_aurora.aws_wrapper</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>software.amazon.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:aws-wrapper:mysql://localhost:3306/test</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/strike-arena.iml" filepath="$PROJECT_DIR$/.idea/strike-arena.iml" />
</modules>
</component>
</project>
Generated
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+164
View File
@@ -0,0 +1,164 @@
# Инструкция по добавлению функционала бронирования с временем
## 📋 Что было добавлено
1. **Выбор времени при бронировании** - на третьем этапе теперь можно выбрать не только дату, но и время
2. **Проверка доступности** - система автоматически проверяет доступность временных слотов с учетом количества компьютеров
3. **Админ-панель** - на главной странице админки теперь отображается информация о всех бронированиях по часам
4. **Сохранение в БД** - все бронирования теперь сохраняются в базу данных
## 🗄️ Обновление базы данных
### Шаг 1: Выполните SQL скрипт
Откройте файл `DATABASE_UPDATE.sql` и выполните его содержимое в вашей MySQL базе данных.
Вы можете сделать это через:
- phpMyAdmin
- MySQL Workbench
- Командную строку MySQL
- Любой другой MySQL клиент
### Шаг 2: Проверьте создание таблицы
После выполнения скрипта проверьте, что таблица `bookings` создана:
```sql
SHOW TABLES LIKE 'bookings';
DESCRIBE bookings;
```
## 📊 Структура таблицы bookings
Таблица содержит следующие поля:
- `id` - уникальный идентификатор
- `type` - тип активности (pc, ps, tv, zayvka)
- `room` - тип комнаты (solo, duo, trio, bootcamp 5, bootcamp 6)
- `date_time` - полная дата и время бронирования
- `time_slot` - временной слот в формате HH:00 (например, 14:00)
- `name` - имя клиента
- `game` - название игры (опционально)
- `contact` - контактный номер телефона
- `contact_method` - способ связи (telegram, whatsapp, phone)
- `status` - статус бронирования (pending, confirmed, cancelled)
- `created_at` - дата создания записи
## 🎮 Количество компьютеров по типам комнат
Система учитывает следующие ограничения:
- **solo** - 5 компьютеров
- **duo** - 4 компьютера
- **trio** - 3 компьютера
- **bootcamp 5** - 2 компьютера
- **bootcamp 6** - 1 компьютер
## 🔧 API Endpoints
### Получение доступных временных слотов
```
GET /api/bookings/available-slots?date=2026-01-25&room=solo
```
Возвращает список всех часов (00:00 - 23:00) с информацией о доступности.
### Создание бронирования
```
POST /api/bookings
Body: {
type: "pc",
room: "solo",
date: "2026-01-25",
time: "14:00",
name: "Иван",
game: "CS2",
contact: "+7 (777) 123-45-67",
contactMethod: "telegram"
}
```
### Получение бронирований (для админки)
```
GET /api/admin/bookings?date=2026-01-25&room=solo
```
### Обновление статуса бронирования
```
PUT /api/admin/bookings
Body: {
id: 1,
status: "confirmed"
}
```
### Удаление бронирования
```
DELETE /api/admin/bookings?id=1
```
## 🎨 Интерфейс
### Форма бронирования
На третьем этапе (`pc3`) теперь:
1. Выбирается дата
2. После выбора даты автоматически загружаются доступные временные слоты
3. Пользователь видит сетку с часами (00:00 - 23:00)
4. Недоступные слоты (когда все компьютеры заняты) отображаются серым цветом и неактивны
5. Доступные слоты можно выбрать
### Админ-панель
На главной странице админки (`/admin`):
1. Выбор даты для просмотра бронирований
2. Фильтр по типу комнаты (или все комнаты)
3. Таблица по часам для каждой комнаты с индикацией занятости:
- 🟢 Зеленый - есть свободные места
- 🔴 Красный - все места заняты
4. Список всех бронирований с возможностью:
- Изменения статуса (Ожидает / Подтверждено / Отменено)
- Удаления бронирования
## ✅ Проверка работы
После установки проверьте:
1. **Форма бронирования:**
- Откройте форму бронирования
- Пройдите до этапа выбора даты и времени
- Убедитесь, что после выбора даты появляются временные слоты
- Попробуйте создать бронирование
2. **Админ-панель:**
- Войдите в админ-панель
- Перейдите на главную страницу
- Убедитесь, что отображаются таблицы с бронированиями по часам
- Попробуйте изменить статус бронирования
3. **База данных:**
- Проверьте, что новые бронирования сохраняются в таблицу `bookings`
- Убедитесь, что данные корректны
## 🐛 Возможные проблемы
### Ошибка "Table 'bookings' doesn't exist"
- Убедитесь, что вы выполнили SQL скрипт из `DATABASE_UPDATE.sql`
### Временные слоты не загружаются
- Проверьте консоль браузера на наличие ошибок
- Убедитесь, что API endpoint `/api/bookings/available-slots` доступен
- Проверьте, что в запросе передаются параметры `date` и `room`
### Бронирование не сохраняется
- Проверьте логи сервера
- Убедитесь, что все обязательные поля заполнены
- Проверьте подключение к базе данных
## 📝 Дополнительные настройки
Если вы хотите изменить количество компьютеров для какого-то типа комнаты, отредактируйте константу `ROOM_CAPACITY` в файлах:
- `src/app/api/bookings/available-slots/route.ts`
- `src/app/admin/page.tsx`
## 🎉 Готово!
Теперь ваша система бронирования поддерживает выбор времени с автоматической проверкой доступности!
+38
View File
@@ -0,0 +1,38 @@
-- SQL инструкция для добавления функционала бронирования с временем
-- Выполните этот скрипт в вашей MySQL базе данных
-- Создание таблицы bookings для хранения бронирований
CREATE TABLE IF NOT EXISTS `bookings` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`type` VARCHAR(50) NOT NULL COMMENT 'Тип активности: pc, ps, tv, zayvka',
`room` VARCHAR(50) NOT NULL COMMENT 'Тип комнаты: solo, duo, trio, bootcamp 5, bootcamp 6',
`date_time` DATETIME NOT NULL COMMENT 'Полная дата и время бронирования',
`time_slot` VARCHAR(5) NOT NULL COMMENT 'Временной слот в формате HH:00 (например, 14:00)',
`name` VARCHAR(255) NOT NULL COMMENT 'Имя клиента',
`game` VARCHAR(255) DEFAULT NULL COMMENT 'Название игры (опционально)',
`contact` VARCHAR(50) NOT NULL COMMENT 'Контактный номер телефона',
`contact_method` VARCHAR(20) DEFAULT NULL COMMENT 'Способ связи: telegram, whatsapp, phone',
`status` ENUM('pending', 'confirmed', 'cancelled') DEFAULT 'pending' COMMENT 'Статус бронирования',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Дата создания записи',
PRIMARY KEY (`id`),
INDEX `idx_date_room_time` (`date_time`, `room`, `time_slot`),
INDEX `idx_status` (`status`),
INDEX `idx_date` (DATE(`date_time`))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Таблица бронирований';
-- Пример запроса для проверки структуры таблицы:
-- DESCRIBE bookings;
-- Пример запроса для просмотра всех бронирований:
-- SELECT * FROM bookings ORDER BY date_time DESC;
-- Пример запроса для просмотра бронирований на конкретную дату:
-- SELECT * FROM bookings WHERE DATE(date_time) = '2026-01-25' ORDER BY time_slot;
-- Пример запроса для подсчета занятых слотов по времени и комнате:
-- SELECT time_slot, room, COUNT(*) as booked_count
-- FROM bookings
-- WHERE DATE(date_time) = '2026-01-25'
-- AND room = 'solo'
-- AND status != 'cancelled'
-- GROUP BY time_slot, room;
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: 'https://strike-arena.kz',
generateRobotsTxt: true,
changefreq: 'weekly',
priority: 0.7,
sitemapSize: 5000,
exclude: ['/admin', '/private'],
};
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// другие настройки
}
module.exports = nextConfig
+6612
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "strike-arena",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--localstorage-file=/tmp/next-dev-storage' next dev",
"build": "NODE_OPTIONS='--localstorage-file=/tmp/next-build-storage' next build",
"start": "NODE_OPTIONS='--localstorage-file=/tmp/next-start-storage' next start",
"lint": "next lint",
"postbuild": "next-sitemap"
},
"dependencies": {
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.6",
"formidable": "^3.5.4",
"framer-motion": "^12.10.1",
"jose": "^6.0.11",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.14.1",
"next": "15.3.2",
"next-sitemap": "^4.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-imask": "^7.6.1",
"styled-components": "^6.1.17",
"zustand": "^5.0.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/bcrypt": "^5.0.2",
"@types/formidable": "^3.4.5",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.15.17",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-input-mask": "^3.0.6",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"typescript": "^5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="34" height="46" viewBox="0 0 34 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.8448 -0.00390625H13.1697L0 22.5475H13.1697L1.89397 45.099L31.9596 16.9097H16.9224L33.8448 -0.00390625Z" fill="url(#paint0_linear_46_101)"/>
<defs>
<linearGradient id="paint0_linear_46_101" x1="0" y1="22.5475" x2="33.8448" y2="22.5475" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 496 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.534973 43.0528C0.534973 44.1598 1.45753 45.1029 2.5851 45.1029H29.2368C30.3849 45.1029 31.2869 44.1598 31.2869 43.0528V41.0026H0.534973V43.0528ZM15.911 16.401C8.22296 16.401 0.534973 20.5013 0.534973 28.7018H31.2869C31.2869 20.5013 23.5989 16.401 15.911 16.401ZM5.90632 24.6016C8.18196 21.4239 13.0203 20.5013 15.911 20.5013C18.8016 20.5013 23.6399 21.4239 25.9156 24.6016H5.90632ZM0.534973 32.8021H31.2869V36.9024H0.534973V32.8021ZM35.3872 8.20052V0H31.2869V8.20052H21.0363L21.5078 12.3008H41.1071L38.2369 41.0026H35.3872V45.1029H38.9134C40.6355 45.1029 42.0501 43.7703 42.2551 42.0892L45.6379 8.20052H35.3872Z" fill="url(#paint0_linear_60_23)"/>
<defs>
<linearGradient id="paint0_linear_60_23" x1="0.534973" y1="22.5514" x2="45.6379" y2="22.5514" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1009 B

+34
View File
@@ -0,0 +1,34 @@
<svg width="51" height="40" viewBox="0 0 51 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50.73 11.3963C50.505 8.29723 49.0704 5.39644 46.7164 3.28075C44.3624 1.16506 41.2648 -0.00758315 38.0502 3.69056e-05H12.6905C9.43883 -0.00421452 6.3097 1.199 3.94985 3.36102C1.59 5.52303 0.179812 8.47859 0.0107258 11.6169C-0.00357526 11.829 -0.00357526 12.0419 0.0107258 12.2541V30.6351C0.00399665 31.7634 0.228984 32.8817 0.672655 33.9254C1.11633 34.969 1.76986 35.9172 2.59541 36.7151C3.42095 37.5129 4.4021 38.1445 5.48201 38.5732C6.56192 39.002 7.71912 39.2194 8.8866 39.2129C12.513 39.2129 14.77 36.6151 16.9763 34.0908C17.7878 33.184 18.6247 32.2281 19.5123 31.3949C20.6895 30.5608 22.0124 29.9382 23.4177 29.5568C24.715 29.4095 26.0257 29.4095 27.3231 29.5568C28.7284 29.9382 30.0512 30.5608 31.2285 31.3949C32.116 32.2281 32.9529 33.184 33.7644 34.0908C35.9707 36.5416 38.2277 39.2129 41.8541 39.2129C43.0216 39.2194 44.1788 39.002 45.2587 38.5732C46.3386 38.1445 47.3198 37.5129 48.1453 36.7151C48.9709 35.9172 49.6244 34.969 50.0681 33.9254C50.5118 32.8817 50.7367 31.7634 50.73 30.6351V12.2541C50.7443 12.0419 50.7443 11.829 50.73 11.6169V11.3963ZM45.6581 30.6351C45.6581 31.6101 45.2573 32.5452 44.5439 33.2346C43.8306 33.924 42.863 34.3113 41.8541 34.3113C40.5862 34.3113 39.3182 32.7428 37.6444 30.9292C36.7822 29.9734 35.8185 28.846 34.7281 27.8412C32.8911 26.4128 30.7665 25.3695 28.4896 24.7777C26.4269 24.4166 24.3138 24.4166 22.2511 24.7777C19.9742 25.3695 17.8497 26.4128 16.0127 27.8412C14.9222 28.846 13.9585 29.9734 13.0963 30.9292C11.524 32.7428 10.1546 34.3113 8.8866 34.3113C7.87773 34.3113 6.91018 33.924 6.1968 33.2346C5.48343 32.5452 5.08265 31.6101 5.08265 30.6351V12.2541C5.09218 12.1317 5.09218 12.0088 5.08265 11.8865C5.18033 10.0009 6.02479 8.22388 7.4411 6.92356C8.85741 5.62325 10.737 4.89929 12.6905 4.90165H38.0502C39.9507 4.89363 41.7855 5.57337 43.1935 6.80708C44.6015 8.04079 45.4807 9.73913 45.6581 11.5678V11.8619C45.6472 11.9924 45.6472 12.1236 45.6581 12.2541V30.6351Z" fill="url(#paint0_linear_60_34)"/>
<path d="M35.536 12.9016C36.9365 12.9016 38.0719 11.8044 38.0719 10.4508C38.0719 9.09726 36.9365 8 35.536 8C34.1354 8 33 9.09726 33 10.4508C33 11.8044 34.1354 12.9016 35.536 12.9016Z" fill="url(#paint1_linear_60_34)"/>
<path d="M40.536 17.9016C41.9365 17.9016 43.0719 16.8044 43.0719 15.4508C43.0719 14.0973 41.9365 13 40.536 13C39.1354 13 38 14.0973 38 15.4508C38 16.8044 39.1354 17.9016 40.536 17.9016Z" fill="url(#paint2_linear_60_34)"/>
<path d="M35.536 22.9016C36.9365 22.9016 38.0719 21.8044 38.0719 20.4508C38.0719 19.0973 36.9365 18 35.536 18C34.1354 18 33 19.0973 33 20.4508C33 21.8044 34.1354 22.9016 35.536 22.9016Z" fill="url(#paint3_linear_60_34)"/>
<path d="M30.536 17.9016C31.9365 17.9016 33.0719 16.8044 33.0719 15.4508C33.0719 14.0973 31.9365 13 30.536 13C29.1354 13 28 14.0973 28 15.4508C28 16.8044 29.1354 17.9016 30.536 17.9016Z" fill="url(#paint4_linear_60_34)"/>
<path d="M15.2265 19.6065C18.0277 19.6065 20.2985 17.4119 20.2985 14.7048C20.2985 11.9978 18.0277 9.80322 15.2265 9.80322C12.4254 9.80322 10.1546 11.9978 10.1546 14.7048C10.1546 17.4119 12.4254 19.6065 15.2265 19.6065Z" fill="url(#paint5_linear_60_34)"/>
<defs>
<linearGradient id="paint0_linear_60_34" x1="0" y1="19.6065" x2="50.7407" y2="19.6065" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
<linearGradient id="paint1_linear_60_34" x1="33" y1="10.4508" x2="38.0719" y2="10.4508" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
<linearGradient id="paint2_linear_60_34" x1="38" y1="15.4508" x2="43.0719" y2="15.4508" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
<linearGradient id="paint3_linear_60_34" x1="33" y1="20.4508" x2="38.0719" y2="20.4508" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
<linearGradient id="paint4_linear_60_34" x1="28" y1="15.4508" x2="33.0719" y2="15.4508" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
<linearGradient id="paint5_linear_60_34" x1="10.1546" y1="14.7048" x2="20.2985" y2="14.7048" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="47" height="48" viewBox="0 0 47 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 4C0 2.93913 0.412648 1.92172 1.14717 1.17157C1.88168 0.421427 2.8779 0 3.91667 0H43.0833C44.1221 0 45.1183 0.421427 45.8528 1.17157C46.5874 1.92172 47 2.93913 47 4V44C47 45.0609 46.5874 46.0783 45.8528 46.8284C45.1183 47.5786 44.1221 48 43.0833 48H3.91667C2.8779 48 1.88168 47.5786 1.14717 46.8284C0.412648 46.0783 0 45.0609 0 44V4ZM16.9722 18.6667C16.9722 19.7275 16.5596 20.7449 15.8251 21.4951C15.0905 22.2452 14.0943 22.6667 13.0556 22.6667C12.0168 22.6667 11.0206 22.2452 10.2861 21.4951C9.55154 20.7449 9.13889 19.7275 9.13889 18.6667C9.13889 17.6058 9.55154 16.5884 10.2861 15.8382C11.0206 15.0881 12.0168 14.6667 13.0556 14.6667C14.0943 14.6667 15.0905 15.0881 15.8251 15.8382C16.5596 16.5884 16.9722 17.6058 16.9722 18.6667ZM7.83333 28.9693C7.83333 28.812 7.89469 28.6013 8.16755 28.3213C8.45217 28.028 8.90389 27.732 9.49792 27.4667C10.6912 26.9333 12.1456 26.6667 13.0556 26.6667C13.9655 26.6667 15.4186 26.9333 16.6132 27.4667C17.2072 27.7307 17.6576 28.0267 17.9436 28.32C18.2177 28.6013 18.2778 28.812 18.2778 28.9693V33.3333H20.8889V28.9693C20.8889 27.9187 20.4058 27.0693 19.7961 26.4413C19.1969 25.8267 18.4253 25.3653 17.6589 25.0227C16.1341 24.3427 14.3233 24 13.0556 24C11.7866 24 9.97706 24.3427 8.45217 25.0227C7.68581 25.3653 6.91422 25.8267 6.31497 26.4413C5.70528 27.0693 5.22222 27.9187 5.22222 28.9693V33.3333H7.83333V28.9693ZM29.0564 28.3213C28.7823 28.6013 28.7222 28.812 28.7222 28.9693V33.3333H26.1111V28.9693C26.1111 27.9187 26.5942 27.0693 27.2039 26.4413C27.8031 25.8267 28.5747 25.3653 29.3411 25.0227C30.8659 24.3427 32.6768 24 33.9444 24C35.2121 24 37.0229 24.3427 38.5478 25.0227C39.3142 25.3653 40.0858 25.8267 40.685 26.4413C41.296 27.0693 41.7778 27.9187 41.7778 28.9693V33.3333H39.1667V28.9693C39.1667 28.812 39.1053 28.6013 38.8324 28.3213C38.5478 28.028 38.0961 27.732 37.5021 27.4667C36.3088 26.9333 34.8544 26.6667 33.9444 26.6667C33.0345 26.6667 31.5814 26.9333 30.3868 27.4667C29.7928 27.7307 29.3424 28.028 29.0564 28.3213ZM33.9444 22.6667C34.9832 22.6667 35.9794 22.2452 36.7139 21.4951C37.4485 20.7449 37.8611 19.7275 37.8611 18.6667C37.8611 17.6058 37.4485 16.5884 36.7139 15.8382C35.9794 15.0881 34.9832 14.6667 33.9444 14.6667C32.9057 14.6667 31.9095 15.0881 31.1749 15.8382C30.4404 16.5884 30.0278 17.6058 30.0278 18.6667C30.0278 19.7275 30.4404 20.7449 31.1749 21.4951C31.9095 22.2452 32.9057 22.6667 33.9444 22.6667ZM27.4167 16C27.4167 17.0609 27.004 18.0783 26.2695 18.8284C25.535 19.5786 24.5388 20 23.5 20C22.4612 20 21.465 19.5786 20.7305 18.8284C19.996 18.0783 19.5833 17.0609 19.5833 16C19.5833 14.9391 19.996 13.9217 20.7305 13.1716C21.465 12.4214 22.4612 12 23.5 12C24.5388 12 25.535 12.4214 26.2695 13.1716C27.004 13.9217 27.4167 14.9391 27.4167 16ZM21.2101 25.5547C21.3014 25.4493 21.4294 25.364 21.7989 25.328C22.0395 25.309 22.2809 25.3037 22.5221 25.312L22.7859 25.3187C23.0078 25.3253 23.2598 25.3333 23.5 25.3333C23.7402 25.3333 23.9922 25.3253 24.2141 25.32L24.4779 25.312C24.719 25.3032 24.9605 25.3081 25.2011 25.3267C25.5706 25.364 25.6973 25.4493 25.7899 25.5547L27.7378 23.7787C27.0524 22.9947 26.1973 22.7467 25.4557 22.672C25.0066 22.628 24.4504 22.644 23.9935 22.656C23.8081 22.6613 23.6384 22.6667 23.5 22.6667C23.3616 22.6667 23.1919 22.6613 23.0065 22.656C22.5496 22.6427 21.9934 22.6293 21.5443 22.6733C20.8027 22.7467 19.9476 22.9947 19.2622 23.7787L21.2101 25.5547Z" fill="url(#paint0_linear_91_41)"/>
<defs>
<linearGradient id="paint0_linear_91_41" x1="0" y1="24" x2="47" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="45" height="46" viewBox="0 0 45 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.7482 4.15752C17.8186 4.15752 15.968 4.92406 14.6036 6.28852C13.2391 7.65297 12.4726 9.50356 12.4726 11.4332C12.4726 13.3628 13.2391 15.2134 14.6036 16.5779C15.968 17.9423 17.8186 18.7089 19.7482 18.7089C21.6779 18.7089 23.5285 17.9423 24.8929 16.5779C26.2574 15.2134 27.0239 13.3628 27.0239 11.4332C27.0239 9.50356 26.2574 7.65297 24.8929 6.28852C23.5285 4.92406 21.6779 4.15752 19.7482 4.15752ZM8.31505 11.4332C8.31505 8.40092 9.51961 5.49284 11.6637 3.3487C13.8079 1.20456 16.716 0 19.7482 0C22.7805 0 25.6886 1.20456 27.8327 3.3487C29.9769 5.49284 31.1814 8.40092 31.1814 11.4332C31.1814 14.4655 29.9769 17.3735 27.8327 19.5177C25.6886 21.6618 22.7805 22.8664 19.7482 22.8664C16.716 22.8664 13.8079 21.6618 11.6637 19.5177C9.51961 17.3735 8.31505 14.4655 8.31505 11.4332ZM30.142 28.0633V35.6175C30.1421 35.9629 30.2282 36.3029 30.3926 36.6067C30.557 36.9104 30.7946 37.1685 31.0837 37.3574L35.3389 40.1367L39.5942 37.3574C39.8833 37.1685 40.1209 36.9104 40.2853 36.6067C40.4497 36.3029 40.5358 35.9629 40.5358 35.6175V28.0633H30.142ZM25.9845 23.9058H44.6934V35.6175C44.693 36.6539 44.4343 37.674 43.9407 38.5853C43.4471 39.4967 42.7341 40.2707 41.8663 40.8373L35.3389 45.1029L28.8116 40.8373C27.9438 40.2707 27.2308 39.4967 26.7372 38.5853C26.2436 37.674 25.9849 36.6539 25.9845 35.6175V23.9058ZM12.4726 29.1027C10.2673 29.1027 8.15232 29.9787 6.59294 31.5381C5.03357 33.0975 4.15752 35.2124 4.15752 37.4177H21.9309V41.5752H0V37.4177C0 34.1098 1.31407 30.9373 3.65313 28.5983C5.99219 26.2592 9.16464 24.9451 12.4726 24.9451H21.827V29.1027H12.4726Z" fill="url(#paint0_linear_60_20)"/>
<defs>
<linearGradient id="paint0_linear_60_20" x1="0" y1="22.5514" x2="44.6934" y2="22.5514" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="37" height="50" viewBox="0 0 37 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2078_2)">
<path d="M20.7836 48.75C25.7266 42.4805 37 27.2852 37 18.75C37 8.39844 28.7135 0 18.5 0C8.28646 0 0 8.39844 0 18.75C0 27.2852 11.2734 42.4805 16.2164 48.75C17.4016 50.2441 19.5984 50.2441 20.7836 48.75ZM18.5 12.5C20.1355 12.5 21.704 13.1585 22.8605 14.3306C24.017 15.5027 24.6667 17.0924 24.6667 18.75C24.6667 20.4076 24.017 21.9973 22.8605 23.1694C21.704 24.3415 20.1355 25 18.5 25C16.8645 25 15.296 24.3415 14.1395 23.1694C12.983 21.9973 12.3333 20.4076 12.3333 18.75C12.3333 17.0924 12.983 15.5027 14.1395 14.3306C15.296 13.1585 16.8645 12.5 18.5 12.5Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2078_2">
<rect width="37" height="50" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="38" height="25" viewBox="0 0 38 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 25V20.8333H37.5V25H0ZM0 14.5833V10.4167H37.5V14.5833H0ZM0 4.16667V0H37.5V4.16667H0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="46" height="40" viewBox="0 0 46 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.7 39L32.99 0.499997H39.535L13.245 39H6.7ZM10.055 21.29C8.185 21.29 6.535 20.8683 5.105 20.025C3.675 19.145 2.53833 17.9167 1.695 16.34C0.888333 14.7267 0.485 12.8383 0.485 10.675C0.485 8.51167 0.888333 6.64167 1.695 5.065C2.53833 3.48833 3.675 2.26 5.105 1.38C6.535 0.5 8.185 0.0599998 10.055 0.0599998C11.925 0.0599998 13.575 0.5 15.005 1.38C16.435 2.26 17.5533 3.48833 18.36 5.065C19.1667 6.64167 19.57 8.51167 19.57 10.675C19.57 12.8383 19.1667 14.7267 18.36 16.34C17.5533 17.9167 16.435 19.145 15.005 20.025C13.575 20.8683 11.925 21.29 10.055 21.29ZM10.055 16.78C11.265 16.78 12.2367 16.285 12.97 15.295C13.7033 14.2683 14.07 12.7283 14.07 10.675C14.07 8.62166 13.7033 7.1 12.97 6.11C12.2367 5.08333 11.265 4.57 10.055 4.57C8.88167 4.57 7.91 5.08333 7.14 6.11C6.40667 7.1 6.04 8.62166 6.04 10.675C6.04 12.6917 6.40667 14.2133 7.14 15.24C7.91 16.2667 8.88167 16.78 10.055 16.78ZM36.18 39.44C34.3467 39.44 32.6967 39.0183 31.23 38.175C29.8 37.295 28.6817 36.0667 27.875 34.49C27.0683 32.8767 26.665 30.9883 26.665 28.825C26.665 26.6617 27.0683 24.7917 27.875 23.215C28.6817 21.6383 29.8 20.41 31.23 19.53C32.6967 18.65 34.3467 18.21 36.18 18.21C38.0867 18.21 39.755 18.65 41.185 19.53C42.615 20.41 43.7333 21.6383 44.54 23.215C45.3467 24.7917 45.75 26.6617 45.75 28.825C45.75 30.9883 45.3467 32.8767 44.54 34.49C43.7333 36.0667 42.615 37.295 41.185 38.175C39.755 39.0183 38.0867 39.44 36.18 39.44ZM36.18 34.93C37.39 34.93 38.3617 34.435 39.095 33.445C39.8283 32.4183 40.195 30.8783 40.195 28.825C40.195 26.8083 39.8283 25.2867 39.095 24.26C38.3617 23.2333 37.39 22.72 36.18 22.72C35.0067 22.72 34.035 23.2333 33.265 24.26C32.5317 25.25 32.165 26.7717 32.165 28.825C32.165 30.8783 32.5317 32.4183 33.265 33.445C34.035 34.435 35.0067 34.93 36.18 34.93Z" fill="url(#paint0_linear_61_117)"/>
<defs>
<linearGradient id="paint0_linear_61_117" x1="-1" y1="19.5" x2="48" y2="19.5" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="46" height="41" viewBox="0 0 46 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.3476 0C10.19 5.89946 5.73214 10.3646 0 10.3646V13.5534C7.80345 13.7135 13.8508 7.65396 13.5311 0H10.3476ZM0 3.66686V6.85789C3.98053 6.69778 6.84435 3.82698 6.84435 0H3.66533C3.52171 0.921249 3.08948 1.77293 2.43105 2.43204C1.77262 3.09116 0.922071 3.52358 0.00225153 3.66686M45.1029 10.3646C39.3707 10.3646 34.9129 5.89946 34.7553 0H31.5695C31.2521 7.65396 37.3017 13.7135 45.1006 13.5534L45.1029 10.3646ZM45.1029 6.85789V3.66686C44.1827 3.52402 43.3316 3.09179 42.6727 2.43264C42.0139 1.77349 41.5813 0.921571 41.4376 0H38.254C38.254 3.82698 41.1201 6.69778 45.0984 6.85564M38.3103 40.5926V22.5514H45.0646L22.5503 2.25514L0.0360229 22.5514H6.79031V40.5926H38.3103ZM22.5503 8.34403L33.8075 18.4922V36.0823H11.2932V18.4922L22.5503 8.34403Z" fill="url(#paint0_linear_60_17)"/>
<defs>
<linearGradient id="paint0_linear_60_17" x1="0" y1="20.2963" x2="45.1029" y2="20.2963" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="46" height="45" viewBox="0 0 46 45" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 44.5391V39.8508H8.30842V29.302H18.9907V18.7533H29.6729V8.20457H40.3552V0H45.1029V12.8929H34.4206V23.4416H23.7384V33.9904H13.0561V44.5391H0Z" fill="url(#paint0_linear_46_95)"/>
<defs>
<linearGradient id="paint0_linear_46_95" x1="0" y1="22.2695" x2="45.1029" y2="22.2695" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="41" height="30" viewBox="0 0 41 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9729 29.4187L0 15.4458L2.94583 12.5L13.9729 23.5271L37.5 0L40.4458 2.94583L13.9729 29.4187Z" fill="#4CE65E"/>
</svg>

After

Width:  |  Height:  |  Size: 226 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="50" height="48" viewBox="0 0 50 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.9626 21.6C24.3005 21.6 23.6656 21.8529 23.1974 22.3029C22.7293 22.753 22.4663 23.3635 22.4663 24C22.4663 24.6365 22.7293 25.247 23.1974 25.6971C23.6656 26.1471 24.3005 26.4 24.9626 26.4C25.6246 26.4 26.2595 26.1471 26.7277 25.6971C27.1958 25.247 27.4588 24.6365 27.4588 24C27.4588 23.3635 27.1958 22.753 26.7277 22.3029C26.2595 21.8529 25.6246 21.6 24.9626 21.6ZM26.2107 0C37.4438 0 37.7184 8.568 31.8273 11.4C29.356 12.576 28.2576 15.096 27.7833 17.328C28.9815 17.808 30.03 18.552 30.8288 19.512C40.0649 14.712 50 16.608 50 25.2C50 36 41.0884 36.24 38.1428 30.552C36.8947 28.176 34.2486 27.12 31.9271 26.664C31.4279 27.816 30.654 28.8 29.6555 29.616C34.6231 38.472 32.651 48 23.7144 48C12.4813 48 12.2566 39.408 18.1478 36.576C20.5941 35.4 21.6925 32.904 22.1917 30.696C20.9685 30.216 19.8952 29.448 19.0964 28.488C9.88517 33.24 0 31.368 0 22.8C0 12 8.88667 11.736 11.8323 17.424C13.0804 19.8 15.7014 20.832 18.023 21.288C18.4973 20.136 19.2961 19.128 20.3195 18.36C15.352 9.504 17.324 0 26.2107 0Z" fill="url(#paint0_linear_91_46)"/>
<defs>
<linearGradient id="paint0_linear_91_46" x1="0" y1="24" x2="50" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="46" height="41" viewBox="0 0 46 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.25514 -0.00390625H42.8477C43.4458 -0.00390625 44.0194 0.233689 44.4424 0.65661C44.8653 1.07953 45.1029 1.65314 45.1029 2.25124V38.3335C45.1029 38.9316 44.8653 39.5053 44.4424 39.9282C44.0194 40.3511 43.4458 40.5887 42.8477 40.5887H2.25514C1.65704 40.5887 1.08344 40.3511 0.660516 39.9282C0.237595 39.5053 0 38.9316 0 38.3335V2.25124C0 1.65314 0.237595 1.07953 0.660516 0.65661C1.08344 0.233689 1.65704 -0.00390625 2.25514 -0.00390625ZM20.2963 12.3994V28.1854H24.8066V12.3994H20.2963ZM19.6536 12.3994H14.8862L11.5103 21.6748L8.13431 12.3994H3.36693L9.25511 28.1042H13.7654L19.6536 12.3994ZM31.572 23.6751H34.9547C36.45 23.6751 37.884 23.0811 38.9413 22.0238C39.9986 20.9665 40.5926 19.5325 40.5926 18.0372C40.5926 16.542 39.9986 15.108 38.9413 14.0507C37.884 12.9934 36.45 12.3994 34.9547 12.3994H27.0617V28.1854H31.572V23.6751ZM31.572 19.1648V16.9097H34.9547C35.2538 16.9097 35.5406 17.0285 35.752 17.2399C35.9635 17.4514 36.0823 17.7382 36.0823 18.0372C36.0823 18.3363 35.9635 18.6231 35.752 18.8346C35.5406 19.046 35.2538 19.1648 34.9547 19.1648H31.572Z" fill="url(#paint0_linear_46_98)"/>
<defs>
<linearGradient id="paint0_linear_46_98" x1="0" y1="20.2924" x2="45.1029" y2="20.2924" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="31" height="63" viewBox="0 0 31 63" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.3 63V44.1H0L9.455 19.845C9.97167 18.585 10.7725 17.5875 11.8575 16.8525C12.9425 16.1175 14.1567 15.75 15.5 15.75C16.8433 15.75 18.0575 16.1175 19.1425 16.8525C20.2275 17.5875 21.0283 18.585 21.545 19.845L31 44.1H21.7V63H9.3ZM15.5 12.6C13.795 12.6 12.3359 11.9837 11.1228 10.751C9.90967 9.51827 9.30207 8.03462 9.3 6.30002C9.29793 4.56542 9.90553 3.08282 11.1228 1.85222C12.3401 0.621621 13.7991 0.00422128 15.5 2.12838e-05C17.2009 -0.00417871 18.661 0.613221 19.8803 1.85222C21.0996 3.09122 21.7062 4.57382 21.7 6.30002C21.6938 8.02622 21.0872 9.50987 19.8803 10.751C18.6734 11.9921 17.2133 12.6084 15.5 12.6Z" fill="url(#paint0_linear_91_34)"/>
<defs>
<linearGradient id="paint0_linear_91_34" x1="0" y1="31.5" x2="31" y2="31.5" gradientUnits="userSpaceOnUse">
<stop offset="0.142948" stop-color="#4CE65E"/>
<stop offset="1" stop-color="#2D5920"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+9
View File
@@ -0,0 +1,9 @@
# *
User-agent: *
Allow: /
# Host
Host: https://strike-arena.kz
# Sitemaps
Sitemap: https://strike-arena.kz/sitemap.xml
+21
View File
@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://strike-arena.kz/admin/contacts</loc><lastmod>2026-01-25T13:27:38.584Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz/admin/games</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz/admin/price</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz/admin/faq</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz/admin/login</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz/policy</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://strike-arena.kz/admin/hardware</loc><lastmod>2026-01-25T13:27:38.585Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
</urlset>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://strike-arena.kz/sitemap-0.xml</loc></sitemap>
</sitemapindex>
+303
View File
@@ -0,0 +1,303 @@
'use client'
import { useEffect, useState, FormEvent } from 'react'
import AdminHeader from '@/components/AdminHeader'
import {
AdminContainer,
AdminContent,
PageTitle,
LoadingSpinner,
Spinner,
RoomCard,
RoomCardHeader,
RoomCardTitle,
FilterInput,
DeleteButton
} from "@/styles/admin.styled"
import styled from 'styled-components'
import { whiteColor, modalColor, lightGreenColor, blackColor } from '@/styles/colors'
const ContactCard = styled.div`
background: rgba(15, 15, 23, 0.6);
border: 1px solid rgba(109, 103, 148, 0.2);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
&:hover {
border-color: rgba(76, 230, 94, 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
`
const ContactInfo = styled.div`
flex: 1;
`
const ContactType = styled.div`
font-size: 0.875rem;
color: ${modalColor};
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
`
const ContactValue = styled.div`
font-size: 1.125rem;
color: ${whiteColor};
word-break: break-word;
`
const EditButton = styled.button`
background: rgba(76, 230, 94, 0.2);
border: 1px solid rgba(76, 230, 94, 0.4);
color: ${lightGreenColor};
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-left: 1rem;
&:hover {
background: rgba(76, 230, 94, 0.3);
border-color: ${lightGreenColor};
transform: translateY(-1px);
}
`
const ModalOverlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
`
const ModalContent = styled.form`
background: rgba(15, 15, 23, 0.95);
border: 1px solid rgba(109, 103, 148, 0.3);
border-radius: 16px;
padding: 2rem;
max-width: 500px;
width: 100%;
`
const ModalHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
`
const ModalTitle = styled.h3`
font-size: 1.5rem;
color: ${whiteColor};
margin: 0;
`
const CloseButton = styled.button`
background: transparent;
border: none;
color: ${modalColor};
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s;
&:hover {
background: rgba(109, 103, 148, 0.2);
color: ${whiteColor};
}
`
const ModalBody = styled.div`
margin-bottom: 1.5rem;
`
const FormLabel = styled.label`
display: block;
font-size: 0.875rem;
color: ${modalColor};
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
`
const FormInput = styled(FilterInput)`
width: 100%;
`
const ModalFooter = styled.div`
display: flex;
gap: 1rem;
justify-content: flex-end;
`
const CancelButton = styled.button`
background: rgba(109, 103, 148, 0.2);
border: 1px solid rgba(109, 103, 148, 0.4);
color: ${modalColor};
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(109, 103, 148, 0.3);
border-color: ${modalColor};
}
`
const SaveButton = styled.button`
background: rgba(76, 230, 94, 0.2);
border: 1px solid rgba(76, 230, 94, 0.4);
color: ${lightGreenColor};
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(76, 230, 94, 0.3);
border-color: ${lightGreenColor};
}
`
interface Contact {
id: number
type: string
value: string
}
export default function AdminContactsPage() {
const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [editRow, setEditRow] = useState<Contact | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
fetchContacts()
}, [])
const fetchContacts = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/contacts')
const data: Contact[] = await res.json()
setContacts(data)
} catch {
setError('Ошибка при загрузке контактов')
} finally {
setLoading(false)
}
}
const handleEditClick = (row: Contact) => {
setEditRow(row)
setShowModal(true)
}
const handleSave = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
const updatedValue = formData.get('value') as string
const res = await fetch('/api/admin/contacts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: editRow?.type, value: updatedValue })
})
if (res.ok) {
await fetchContacts()
setShowModal(false)
setEditRow(null)
} else {
alert('Ошибка при сохранении')
}
}
return (
<AdminContainer>
<AdminHeader />
<AdminContent>
<PageTitle>Контакты</PageTitle>
{loading ? (
<LoadingSpinner>
<Spinner />
</LoadingSpinner>
) : error ? (
<div style={{ color: '#dc3545', padding: '2rem' }}>{error}</div>
) : (
<div>
{contacts.map((row) => (
<ContactCard key={row.id}>
<ContactInfo>
<ContactType>{row.type}</ContactType>
<ContactValue>{row.value}</ContactValue>
</ContactInfo>
<EditButton onClick={() => handleEditClick(row)}>
Редактировать
</EditButton>
</ContactCard>
))}
</div>
)}
{showModal && editRow && (
<ModalOverlay onClick={() => setShowModal(false)}>
<ModalContent onClick={(e) => e.stopPropagation()} onSubmit={handleSave}>
<ModalHeader>
<ModalTitle>Редактировать контакт</ModalTitle>
<CloseButton type="button" onClick={() => setShowModal(false)}>
×
</CloseButton>
</ModalHeader>
<ModalBody>
<FormLabel>Тип: {editRow.type}</FormLabel>
<FormLabel style={{ marginTop: '1rem' }}>Значение</FormLabel>
<FormInput
name="value"
type="text"
defaultValue={editRow.value}
required
/>
</ModalBody>
<ModalFooter>
<CancelButton type="button" onClick={() => setShowModal(false)}>
Отмена
</CancelButton>
<SaveButton type="submit">
Сохранить
</SaveButton>
</ModalFooter>
</ModalContent>
</ModalOverlay>
)}
</AdminContent>
</AdminContainer>
)
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useEffect, useState, FormEvent } from 'react'
import AdminHeader from '@/components/AdminHeader'
interface Faq {
id: number
title: string
description: string
}
export default function AdminFaqPage() {
const [faqs, setFaqs] = useState<Faq[]>([])
const [loading, setLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [editingFaq, setEditingFaq] = useState<Faq | null>(null)
useEffect(() => {
fetchFaqs()
}, [])
const fetchFaqs = async () => {
setLoading(true)
const res = await fetch('/api/admin/faq')
const data: Faq[] = await res.json()
setFaqs(data)
setLoading(false)
}
const handleDelete = async (id: number) => {
if (!confirm('Удалить вопрос?')) return
const res = await fetch(`/api/admin/faq?id=${id}`, { method: 'DELETE' })
if (res.ok) fetchFaqs()
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
const payload = Object.fromEntries(formData.entries()) as {
title: string
description: string
}
const method = editingFaq ? 'PUT' : 'POST'
const res = await fetch('/api/admin/faq', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingFaq ? { ...editingFaq, ...payload } : payload),
})
if (res.ok) {
fetchFaqs()
setShowModal(false)
setEditingFaq(null)
}
}
return (
<div>
<AdminHeader />
<div className="container py-5 text-light">
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="fs-1">Часто задаваемые вопросы</h2>
<button className="btn btn-success" onClick={() => setShowModal(true)}>
+ Добавить вопрос
</button>
</div>
{loading ? (
<p>Загрузка...</p>
) : (
<div className="table-responsive">
<table className="table table-dark table-bordered">
<thead>
<tr>
<th>Заголовок</th>
<th>Описание</th>
<th></th>
</tr>
</thead>
<tbody>
{faqs.map((faq) => (
<tr key={faq.id}>
<td>{faq.title}</td>
<td>{faq.description}</td>
<td>
<button
className="btn btn-warning btn-sm me-2"
onClick={() => {
setEditingFaq(faq)
setShowModal(true)
}}
>
</button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(faq.id)}>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showModal && (
<div className="modal d-block bg-dark bg-opacity-75" tabIndex={-1}>
<div className="modal-dialog">
<form className="modal-content bg-dark text-white" onSubmit={handleSubmit}>
<div className="modal-header">
<h5 className="modal-title">{editingFaq ? 'Редактировать' : 'Добавить'} вопрос</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={() => {
setShowModal(false)
setEditingFaq(null)
}}
/>
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
name="title"
defaultValue={editingFaq?.title || ''}
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Описание</label>
<textarea
name="description"
defaultValue={editingFaq?.description || ''}
className="form-control"
required
rows={5}
/>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setShowModal(false)
setEditingFaq(null)
}}
>
Отмена
</button>
<button type="submit" className="btn btn-success">
Сохранить
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)
}
+154
View File
@@ -0,0 +1,154 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import AdminHeader from "@/components/AdminHeader"
import Image from "next/image"
type Game = {
id: number
title: string
image_url: string
platform: string
}
export default function AdminGamesPage() {
const [games, setGames] = useState<Game[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showModal, setShowModal] = useState(false)
useEffect(() => {
fetchGames()
}, [])
const fetchGames = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/games')
const data: Game[] = await res.json()
setGames(data)
} catch {
setError('Ошибка при загрузке игр')
} finally {
setLoading(false)
}
}
const handleDelete = async (id: number) => {
const confirmed = confirm('Удалить игру?')
if (!confirmed) return
const res = await fetch(`/api/admin/games?id=${id}`, { method: 'DELETE' })
if (res.ok) {
setGames((prev) => prev.filter((g) => g.id !== id))
} else {
alert('Ошибка при удалении')
}
}
const handleAddGame = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
const res = await fetch('/api/admin/games/upload', {
method: 'POST',
body: formData,
})
if (res.ok) {
const added: Game = await res.json()
setGames((prev) => [...prev, added])
setShowModal(false)
} else {
alert('Ошибка при добавлении игры')
}
}
const imageLoader = ({ src }: { src: string }) => `/api/uploads/games/${src}`
return (
<div>
<AdminHeader />
<div className="container py-5 text-light">
<div className="text-center mb-5">
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
Добавить новую игру
</button>
</div>
<h2 className="mb-4 fs-1 text-center">Список игр</h2>
{loading && <p>Загрузка...</p>}
{error && <p className="text-danger">{error}</p>}
<div className="row row-cols-1 row-cols-md-4 g-4">
{games.map((game) => (
<div key={game.id} className="col">
<div className="card bg-dark text-white h-100">
{game.image_url && (
<Image
loader={() => imageLoader({ src: game.image_url })}
src={`/api/uploads/games/${game.image_url}`}
alt={game.title}
className="card-img-top"
width={400}
height={160}
style={{ objectFit: 'cover', width: '100%', height: 160 }}
/>
)}
<div className="card-body">
<h5 className="card-title">{game.title}</h5>
<p className="card-text">Платформа: {game.platform}</p>
<button className="btn btn-danger" onClick={() => handleDelete(game.id)}>
Удалить
</button>
</div>
</div>
</div>
))}
</div>
{showModal && (
<div className="modal d-block bg-black bg-opacity-75" tabIndex={-1} role="dialog">
<div className="modal-dialog modal-dialog-centered" role="document">
<form className="modal-content bg-dark text-white" onSubmit={handleAddGame} encType="multipart/form-data">
<div className="modal-header">
<h5 className="modal-title">Новая игра</h5>
<button type="button" className="btn-close btn-close-white" onClick={() => setShowModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input name="title" type="text" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Платформа</label>
<select name="platform" className="form-select" defaultValue="pc">
<option value="pc">PC</option>
<option value="ps">PS</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Изображение</label>
<input name="image" type="file" accept="image/*" className="form-control" required />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Отмена
</button>
<button type="submit" className="btn btn-success">
Сохранить
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
'use client'
import { useEffect, useState, FormEvent } from 'react'
import AdminHeader from '@/components/AdminHeader'
const fields = ['name', 'description', 'cpu', 'ram', 'gpu', 'mouse', 'keyboard', 'headphones', 'monitor', 'bracket', 'chair'] as const
type Room = {
id: number
name: string
description: string
cpu?: string
ram?: string
gpu?: string
mouse?: string
keyboard?: string
headphones?: string
monitor?: string
bracket?: string
chair?: string
}
export default function AdminRoomsPage() {
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | null>(null)
useEffect(() => {
fetchRooms()
}, [])
const fetchRooms = async () => {
setLoading(true)
const res = await fetch('/api/admin/rooms')
const data: Room[] = await res.json()
setRooms(data)
setLoading(false)
}
const handleDelete = async (id: number) => {
if (!confirm('Удалить комнату?')) return
const res = await fetch(`/api/admin/rooms?id=${id}`, { method: 'DELETE' })
if (res.ok) fetchRooms()
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
const payload = Object.fromEntries(formData.entries())
const method = editingRoom ? 'PUT' : 'POST'
const res = await fetch('/api/admin/rooms', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRoom ? { ...editingRoom, ...payload } : payload)
})
if (res.ok) {
fetchRooms()
setShowModal(false)
setEditingRoom(null)
}
}
const standardRooms = rooms.filter(r => r.name.toLowerCase() !== 'ps')
const psRooms = rooms.filter(r => r.name.toLowerCase() === 'ps')
return (
<div>
<AdminHeader />
<div className="container py-5 text-light">
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="fs-1">Железо</h2>
<button className="btn btn-success" onClick={() => setShowModal(true)}>
+ Добавить комнату
</button>
</div>
{loading ? <p>Загрузка...</p> : (
<>
<div className="table-responsive mb-5">
<h4 className="mb-3">Стандартные комнаты</h4>
<table className="table table-dark table-bordered">
<thead>
<tr>
{fields.map(f => <th key={f}>{f}</th>)}
<th></th>
</tr>
</thead>
<tbody>
{standardRooms.map((room) => (
<tr key={room.id}>
{fields.map(f => <td key={f}>{room[f]}</td>)}
<td>
<button className="btn btn-warning btn-sm me-2" onClick={() => { setEditingRoom(room); setShowModal(true) }}></button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(room.id)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{psRooms.length > 0 && (
<div className="table-responsive">
<h4 className="mb-3">Консольные комнаты (PS)</h4>
<table className="table table-dark table-bordered">
<thead>
<tr>
<th>name</th>
<th>description</th>
<th>attachment</th>
<th>gamepad</th>
<th>tv</th>
<th></th>
</tr>
</thead>
<tbody>
{psRooms.map((room) => (
<tr key={room.id}>
<td>{room.name}</td>
<td>{room.description}</td>
<td>{room.cpu}</td>
<td>{room.ram}</td>
<td>{room.gpu}</td>
<td>
<button className="btn btn-warning btn-sm me-2" onClick={() => { setEditingRoom(room); setShowModal(true) }}></button>
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(room.id)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{showModal && (
<div className="modal d-block bg-dark bg-opacity-75" tabIndex={-1}>
<div className="modal-dialog modal-dialog-scrollable">
<form className="modal-content bg-dark text-white" onSubmit={handleSubmit}>
<div className="modal-header">
<h5 className="modal-title">{editingRoom ? 'Редактировать' : 'Добавить'} комнату</h5>
<button type="button" className="btn-close btn-close-white" onClick={() => { setShowModal(false); setEditingRoom(null) }} />
</div>
<div className="modal-body">
{fields.map(field => (
<div className="mb-3" key={field}>
<label className="form-label">{field}</label>
<input
name={field}
defaultValue={editingRoom?.[field] || ''}
className="form-control"
required={field === 'name'}
/>
</div>
))}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => { setShowModal(false); setEditingRoom(null) }}>
Отмена
</button>
<button type="submit" className="btn btn-success">Сохранить</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function AdminLoginPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const handleLogin = async () => {
const res = await fetch('/api/admin-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (res.ok) {
router.push('/admin')
} else {
const data = await res.json()
setError(data.error || 'Ошибка авторизации')
}
}
return (
<div className="min-vh-100 d-flex justify-content-center align-items-center bg-black text-light px-3">
<div className="card bg-dark text-white shadow-lg border-0" style={{ width: '100%', maxWidth: 400 }}>
<div className="card-body p-4">
<h3 className="card-title text-center mb-4">Админ-панель</h3>
<div className="form-group mb-3">
<label htmlFor="password" className="form-label">
Пароль
</label>
<input
type="password"
className="form-control bg-secondary border-0 text-white"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Введите пароль"
/>
</div>
{error && <div className="alert alert-danger py-2">{error}</div>}
<button className="btn btn-primary w-100 mt-2" onClick={handleLogin}>
Войти
</button>
</div>
</div>
</div>
)
}
+592
View File
@@ -0,0 +1,592 @@
'use client'
import { useEffect, useState } from 'react'
import AdminHeader from "@/components/AdminHeader"
import {
AdminContainer,
AdminContent,
PageTitle,
FiltersContainer,
FilterGroup,
FilterLabel,
FilterInput,
FilterSelect,
RoomCard,
RoomCardHeader,
RoomCardTitle,
RoomCapacity,
TimeGrid,
TimeSlot,
TimeSlotHeader,
TimeSlotTime,
TimeSlotCount,
TimeSlotBookings,
BookingBadge,
BookingsList,
BookingCard,
BookingCardHeader,
BookingCardInfo,
BookingCardTitle,
BookingCardMeta,
BookingCardActions,
StatusSelect,
DeleteButton,
EmptyState,
EmptyStateIcon,
EmptyStateText,
LoadingSpinner,
Spinner,
SectionTitle,
AddButton,
ModalOverlay,
ModalContent,
ModalHeader,
ModalTitle,
ModalCloseButton,
ModalBody,
FormRow,
FormLabel,
FormInput as StyledFormInput,
FormSelect as StyledFormSelect,
ModalFooter,
CancelButton,
SubmitButton,
ErrorMessage
} from "@/styles/admin.styled"
type Booking = {
id: number
type: string
room: string
date_time: string
time_slot: string
name: string
game: string | null
contact: string
contact_method: string | null
status: 'pending' | 'confirmed' | 'cancelled'
created_at: string
}
const ROOM_NAMES: Record<string, string> = {
'solo': 'Solo',
'duo': 'Duo',
'trio': 'Trio',
'bootcamp 5': 'Bootcamp 5',
'bootcamp 6': 'Bootcamp 6',
}
const ROOM_CAPACITY: Record<string, number> = {
'solo': 5,
'duo': 4,
'trio': 3,
'bootcamp 5': 2,
'bootcamp 6': 1,
}
const STATUS_NAMES: Record<string, string> = {
'pending': 'Ожидает',
'confirmed': 'Подтверждено',
'cancelled': 'Отменено',
}
export default function AdminHomePage() {
const [selectedDate, setSelectedDate] = useState<string>(new Date().toISOString().split('T')[0])
const [selectedRoom, setSelectedRoom] = useState<string>('all')
const [bookings, setBookings] = useState<Booking[]>([])
const [loading, setLoading] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const [formError, setFormError] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [newBooking, setNewBooking] = useState({
type: 'pc',
room: 'solo',
date: new Date().toISOString().split('T')[0],
time: '12:00',
name: '',
game: '',
contact: '',
contactMethod: 'telegram',
status: 'pending'
})
useEffect(() => {
fetchBookings()
}, [selectedDate, selectedRoom])
const fetchBookings = async () => {
setLoading(true)
try {
const params = new URLSearchParams({ date: selectedDate })
if (selectedRoom !== 'all') {
params.append('room', selectedRoom)
}
const res = await fetch(`/api/admin/bookings?${params}`)
if (res.ok) {
const data = await res.json()
setBookings(data.bookings || [])
}
} catch (error) {
console.error('Ошибка при загрузке бронирований:', error)
} finally {
setLoading(false)
}
}
const updateStatus = async (id: number, status: string) => {
try {
const res = await fetch('/api/admin/bookings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
})
if (res.ok) {
fetchBookings()
}
} catch (error) {
console.error('Ошибка при обновлении статуса:', error)
}
}
const deleteBooking = async (id: number) => {
if (!confirm('Вы уверены, что хотите удалить это бронирование?')) return
try {
const res = await fetch(`/api/admin/bookings?id=${id}`, {
method: 'DELETE',
})
if (res.ok) {
fetchBookings()
}
} catch (error) {
console.error('Ошибка при удалении бронирования:', error)
}
}
const handleAddBooking = async (e: React.FormEvent) => {
e.preventDefault()
setFormError('')
setSubmitting(true)
try {
const res = await fetch('/api/admin/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newBooking),
})
const data = await res.json()
if (res.ok) {
setShowAddModal(false)
setNewBooking({
type: 'pc',
room: 'solo',
date: new Date().toISOString().split('T')[0],
time: '12:00',
name: '',
game: '',
contact: '',
contactMethod: 'telegram',
status: 'pending'
})
fetchBookings()
} else {
setFormError(data.error || 'Ошибка при создании бронирования')
}
} catch (error) {
console.error('Ошибка при создании бронирования:', error)
setFormError('Ошибка сети. Попробуйте еще раз.')
} finally {
setSubmitting(false)
}
}
// Группируем бронирования по комнатам и времени
const bookingsByRoomAndTime: Record<string, Record<string, Booking[]>> = {}
bookings.forEach(booking => {
if (booking.status === 'cancelled') return
if (!bookingsByRoomAndTime[booking.room]) {
bookingsByRoomAndTime[booking.room] = {}
}
if (!bookingsByRoomAndTime[booking.room][booking.time_slot]) {
bookingsByRoomAndTime[booking.room][booking.time_slot] = []
}
bookingsByRoomAndTime[booking.room][booking.time_slot].push(booking)
})
// Генерируем все часы
const allHours = Array.from({ length: 24 }, (_, i) => {
return `${i.toString().padStart(2, '0')}:00`
})
const rooms = ['solo', 'duo', 'trio', 'bootcamp 5', 'bootcamp 6']
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('ru-RU', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
return (
<AdminContainer>
<AdminHeader/>
<AdminContent>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<PageTitle style={{ margin: 0 }}>Управление бронированиями</PageTitle>
<AddButton onClick={() => setShowAddModal(true)}>
<span>+</span>
<span>Добавить бронирование</span>
</AddButton>
</div>
<FiltersContainer>
<FilterGroup>
<FilterLabel>Дата</FilterLabel>
<FilterInput
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
/>
</FilterGroup>
<FilterGroup>
<FilterLabel>Тип комнаты</FilterLabel>
<FilterSelect
value={selectedRoom}
onChange={(e) => setSelectedRoom(e.target.value)}
>
<option value="all">Все комнаты</option>
{rooms.map(room => (
<option key={room} value={room}>{ROOM_NAMES[room]}</option>
))}
</FilterSelect>
</FilterGroup>
</FiltersContainer>
{loading ? (
<LoadingSpinner>
<Spinner />
</LoadingSpinner>
) : (
<>
{/* Расписание по часам */}
{selectedRoom === 'all' ? (
rooms.map(room => {
const roomBookings = bookingsByRoomAndTime[room] || {}
const capacity = ROOM_CAPACITY[room]
return (
<RoomCard key={room}>
<RoomCardHeader>
<RoomCardTitle>{ROOM_NAMES[room]}</RoomCardTitle>
<RoomCapacity>{capacity} мест</RoomCapacity>
</RoomCardHeader>
<TimeGrid>
{allHours.map(hour => {
const hourBookings = roomBookings[hour] || []
const booked = hourBookings.length
const isFull = booked >= capacity
return (
<TimeSlot
key={hour}
$available={!isFull}
$booked={booked}
$capacity={capacity}
>
<TimeSlotHeader>
<TimeSlotTime>{hour}</TimeSlotTime>
<TimeSlotCount $full={isFull}>
{booked}/{capacity}
</TimeSlotCount>
</TimeSlotHeader>
<TimeSlotBookings>
{hourBookings.length > 0 ? (
hourBookings.map(booking => (
<BookingBadge
key={booking.id}
$status={booking.status}
title={`${booking.name} - ${booking.contact}`}
>
{booking.name}
</BookingBadge>
))
) : (
<span style={{ fontSize: '0.75rem', color: 'rgba(109, 103, 148, 0.5)' }}>
Свободно
</span>
)}
</TimeSlotBookings>
</TimeSlot>
)
})}
</TimeGrid>
</RoomCard>
)
})
) : (
(() => {
const roomBookings = bookingsByRoomAndTime[selectedRoom] || {}
const capacity = ROOM_CAPACITY[selectedRoom]
return (
<RoomCard>
<RoomCardHeader>
<RoomCardTitle>{ROOM_NAMES[selectedRoom]}</RoomCardTitle>
<RoomCapacity>{capacity} мест</RoomCapacity>
</RoomCardHeader>
<TimeGrid>
{allHours.map(hour => {
const hourBookings = roomBookings[hour] || []
const booked = hourBookings.length
const isFull = booked >= capacity
return (
<TimeSlot
key={hour}
$available={!isFull}
$booked={booked}
$capacity={capacity}
>
<TimeSlotHeader>
<TimeSlotTime>{hour}</TimeSlotTime>
<TimeSlotCount $full={isFull}>
{booked}/{capacity}
</TimeSlotCount>
</TimeSlotHeader>
<TimeSlotBookings>
{hourBookings.length > 0 ? (
hourBookings.map(booking => (
<BookingBadge
key={booking.id}
$status={booking.status}
title={`${booking.name} - ${booking.contact}`}
>
{booking.name}
</BookingBadge>
))
) : (
<span style={{ fontSize: '0.75rem', color: 'rgba(109, 103, 148, 0.5)' }}>
Свободно
</span>
)}
</TimeSlotBookings>
</TimeSlot>
)
})}
</TimeGrid>
</RoomCard>
)
})()
)}
{/* Список всех бронирований */}
<SectionTitle>
Все бронирования на {formatDate(selectedDate)}
</SectionTitle>
{bookings.length === 0 ? (
<EmptyState>
<EmptyStateIcon>📅</EmptyStateIcon>
<EmptyStateText>Нет бронирований на эту дату</EmptyStateText>
</EmptyState>
) : (
<BookingsList>
{bookings.map(booking => (
<BookingCard key={booking.id} $status={booking.status}>
<BookingCardHeader>
<BookingCardInfo>
<BookingCardTitle>{booking.name}</BookingCardTitle>
<BookingCardMeta>
<span>🕐 {booking.time_slot}</span>
<span>🖥 {ROOM_NAMES[booking.room] || booking.room}</span>
{booking.game && <span>🎮 {booking.game}</span>}
<span>📞 {booking.contact}</span>
{booking.contact_method && (
<span>💬 {booking.contact_method}</span>
)}
</BookingCardMeta>
</BookingCardInfo>
<BookingCardActions>
<StatusSelect
$status={booking.status}
value={booking.status}
onChange={(e) => updateStatus(booking.id, e.target.value)}
>
<option value="pending">Ожидает</option>
<option value="confirmed">Подтверждено</option>
<option value="cancelled">Отменено</option>
</StatusSelect>
<DeleteButton onClick={() => deleteBooking(booking.id)}>
Удалить
</DeleteButton>
</BookingCardActions>
</BookingCardHeader>
</BookingCard>
))}
</BookingsList>
)}
</>
)}
{/* Модальное окно для добавления бронирования */}
{showAddModal && (
<ModalOverlay onClick={() => !submitting && setShowAddModal(false)}>
<ModalContent onClick={(e) => e.stopPropagation()} onSubmit={handleAddBooking}>
<ModalHeader>
<ModalTitle>Добавить бронирование</ModalTitle>
<ModalCloseButton
type="button"
onClick={() => !submitting && setShowAddModal(false)}
>
×
</ModalCloseButton>
</ModalHeader>
<ModalBody>
<FormRow>
<div>
<FormLabel>Тип активности</FormLabel>
<StyledFormSelect
value={newBooking.type}
onChange={(e) => setNewBooking({ ...newBooking, type: e.target.value })}
required
>
<option value="pc">Играть на ПК</option>
<option value="ps">Играть на PS</option>
<option value="tv">Смотреть трансляции</option>
<option value="zayvka">Заявка</option>
</StyledFormSelect>
</div>
<div>
<FormLabel>Тип комнаты</FormLabel>
<StyledFormSelect
value={newBooking.room}
onChange={(e) => setNewBooking({ ...newBooking, room: e.target.value })}
required
>
<option value="solo">Solo (5 мест)</option>
<option value="duo">Duo (4 места)</option>
<option value="trio">Trio (3 места)</option>
<option value="bootcamp 5">Bootcamp 5 (2 места)</option>
<option value="bootcamp 6">Bootcamp 6 (1 место)</option>
</StyledFormSelect>
</div>
</FormRow>
<FormRow>
<div>
<FormLabel>Дата</FormLabel>
<StyledFormInput
type="date"
value={newBooking.date}
onChange={(e) => setNewBooking({ ...newBooking, date: e.target.value })}
min={new Date().toISOString().split('T')[0]}
required
/>
</div>
<div>
<FormLabel>Время</FormLabel>
<StyledFormSelect
value={newBooking.time}
onChange={(e) => setNewBooking({ ...newBooking, time: e.target.value })}
required
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, '0')
return (
<option key={hour} value={`${hour}:00`}>
{hour}:00
</option>
)
})}
</StyledFormSelect>
</div>
</FormRow>
<div>
<FormLabel>Имя клиента</FormLabel>
<StyledFormInput
type="text"
value={newBooking.name}
onChange={(e) => setNewBooking({ ...newBooking, name: e.target.value })}
placeholder="Введите имя"
required
/>
</div>
<div>
<FormLabel>Игра (необязательно)</FormLabel>
<StyledFormInput
type="text"
value={newBooking.game}
onChange={(e) => setNewBooking({ ...newBooking, game: e.target.value })}
placeholder="Название игры"
/>
</div>
<div>
<FormLabel>Контактный номер</FormLabel>
<StyledFormInput
type="text"
value={newBooking.contact}
onChange={(e) => setNewBooking({ ...newBooking, contact: e.target.value })}
placeholder="+7 (777) 123-45-67"
required
/>
</div>
<FormRow>
<div>
<FormLabel>Способ связи</FormLabel>
<StyledFormSelect
value={newBooking.contactMethod}
onChange={(e) => setNewBooking({ ...newBooking, contactMethod: e.target.value })}
>
<option value="telegram">Telegram</option>
<option value="whatsapp">WhatsApp</option>
<option value="phone">Телефон</option>
</StyledFormSelect>
</div>
<div>
<FormLabel>Статус</FormLabel>
<StyledFormSelect
value={newBooking.status}
onChange={(e) => setNewBooking({ ...newBooking, status: e.target.value })}
>
<option value="pending">Ожидает</option>
<option value="confirmed">Подтверждено</option>
<option value="cancelled">Отменено</option>
</StyledFormSelect>
</div>
</FormRow>
{formError && <ErrorMessage>{formError}</ErrorMessage>}
</ModalBody>
<ModalFooter>
<CancelButton
type="button"
onClick={() => setShowAddModal(false)}
disabled={submitting}
>
Отмена
</CancelButton>
<SubmitButton type="submit" disabled={submitting}>
{submitting ? 'Создание...' : 'Создать бронирование'}
</SubmitButton>
</ModalFooter>
</ModalContent>
</ModalOverlay>
)}
</AdminContent>
</AdminContainer>
)
}
+190
View File
@@ -0,0 +1,190 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import AdminHeader from '@/components/AdminHeader'
type Price = {
id: number
game_type: 'ПК' | 'PS'
room_type: string
day_type: string
hour_1: number
hour_3: number
hour_5: number
night: number
}
export default function AdminPricesPage() {
const [prices, setPrices] = useState<Price[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [editRow, setEditRow] = useState<Price | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
fetchPrices()
}, [])
const fetchPrices = async () => {
setLoading(true)
try {
const res = await fetch('/api/admin/price')
const data: Price[] = await res.json()
setPrices(data)
} catch {
setError('Ошибка при загрузке тарифов')
} finally {
setLoading(false)
}
}
const handleEditClick = (row: Price) => {
setEditRow(row)
setShowModal(true)
}
const handleSave = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const formData = new FormData(form)
const updated = Object.fromEntries(formData.entries()) as Record<string, string>
const res = await fetch('/api/admin/price', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...editRow, ...updated })
})
if (res.ok) {
await fetchPrices()
setShowModal(false)
setEditRow(null)
} else {
alert('Ошибка при сохранении')
}
}
return (
<div>
<AdminHeader />
<div className="container py-5 text-light">
<h2 className="mb-4 fs-1 text-center">Цены на ПК</h2>
{loading && <p>Загрузка...</p>}
{error && <p className="text-danger">{error}</p>}
<div className="table-responsive">
<table className="table table-dark table-bordered align-middle text-center">
<thead>
<tr>
<th>Тип игры</th>
<th>Тип комнаты</th>
<th>Тип дня</th>
<th>1 час</th>
<th>3 часа</th>
<th>5 часов</th>
<th>Ночь</th>
<th></th>
</tr>
</thead>
<tbody>
{prices.map((row, idx) =>
row.game_type === 'ПК' ? (
<tr key={idx}>
<td>{row.game_type}</td>
<td>{row.room_type}</td>
<td>{row.day_type}</td>
<td>{row.hour_1}</td>
<td>{row.hour_3}</td>
<td>{row.hour_5}</td>
<td>{row.night}</td>
<td>
<button className="btn btn-sm btn-warning" onClick={() => handleEditClick(row)}>
Редактировать
</button>
</td>
</tr>
) : null
)}
</tbody>
</table>
</div>
<h2 className="mb-4 fs-1 text-center">Цены на PS</h2>
<div className="table-responsive">
<table className="table table-dark table-bordered align-middle text-center">
<thead>
<tr>
<th>Тип игры</th>
<th>Тип комнаты</th>
<th>Тип дня</th>
<th>1 час</th>
<th>3 часа</th>
<th>5 часов</th>
<th>Ночь</th>
<th></th>
</tr>
</thead>
<tbody>
{prices.map((row, idx) =>
row.game_type === 'PS' ? (
<tr key={idx}>
<td>{row.game_type}</td>
<td>{row.room_type}</td>
<td>{row.day_type}</td>
<td>{row.hour_1}</td>
<td>{row.hour_3}</td>
<td>{row.hour_5}</td>
<td>{row.night}</td>
<td>
<button className="btn btn-sm btn-warning" onClick={() => handleEditClick(row)}>
Редактировать
</button>
</td>
</tr>
) : null
)}
</tbody>
</table>
</div>
{showModal && editRow && (
<div className="modal d-block bg-dark bg-opacity-75" tabIndex={-1}>
<div className="modal-dialog modal-dialog-centered">
<form className="modal-content bg-dark text-white" onSubmit={handleSave}>
<div className="modal-header">
<h5 className="modal-title">Редактировать тариф</h5>
<button type="button" className="btn-close btn-close-white" onClick={() => setShowModal(false)} />
</div>
<div className="modal-body">
{['hour_1', 'hour_3', 'hour_5', 'night'].map((field) => (
<div className="mb-3" key={field}>
<label className="form-label">{field.replace('_', ' ')}:</label>
<input
name={field}
type="number"
step="0.01"
defaultValue={editRow[field as keyof Price]?.toString() || ''}
className="form-control"
required
/>
</div>
))}
</div>
<div className="modal-footer">
<button className="btn btn-secondary" type="button" onClick={() => setShowModal(false)}>
Отмена
</button>
<button className="btn btn-success" type="submit">
Сохранить
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
const ADMIN_HASHED_PASSWORD = "$2b$10$JQm79m.C64TWTwc7qmbJLOW2UnDLCUsL5e34eFb1MhRPqGBBxw0QK";
export async function POST(req: Request) {
const { password } = await req.json();
try {
const isValid = await bcrypt.compare(password, ADMIN_HASHED_PASSWORD);
if (!isValid) {
return NextResponse.json({ error: 'Неверный пароль' }, { status: 401 });
}
const token = jwt.sign({ role: 'admin' }, process.env.JWT_SECRET!, { expiresIn: "1h" });
const res = NextResponse.json({ success: true });
res.cookies.set('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 3600
});
return res;
} catch (error) {
console.error('Ошибка при выполнении запроса:', error);
return NextResponse.json({ error: 'Произошла ошибка при обработке запроса.' }, { status: 500 });
}
}
+174
View File
@@ -0,0 +1,174 @@
import { NextRequest, NextResponse } from 'next/server';
import { bd } from '@/lib/bd';
import { verifyAdminToken } from '@/lib/verifyAdmin';
// GET - получить все бронирования для админки
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!verifyAdminToken(token)) {
return NextResponse.json({ error: 'Нет доступа' }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const date = searchParams.get('date');
const room = searchParams.get('room');
let query = 'SELECT * FROM bookings WHERE 1=1';
const params: any[] = [];
if (date) {
query += ' AND DATE(date_time) = ?';
params.push(date);
}
if (room) {
query += ' AND room = ?';
params.push(room);
}
query += ' ORDER BY date_time DESC, time_slot ASC';
const [bookings] = await bd.query(query, params);
return NextResponse.json({ bookings });
} catch (error) {
console.error('Ошибка при получении бронирований:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
// PUT - обновить статус бронирования
export async function PUT(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!verifyAdminToken(token)) {
return NextResponse.json({ error: 'Нет доступа' }, { status: 401 });
}
try {
const { id, status } = await req.json();
if (!id || !status) {
return NextResponse.json(
{ error: 'Необходимы id и status' },
{ status: 400 }
);
}
await bd.query(
'UPDATE bookings SET status = ? WHERE id = ?',
[status, id]
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Ошибка при обновлении бронирования:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
// POST - создать новое бронирование (для админки)
export async function POST(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!verifyAdminToken(token)) {
return NextResponse.json({ error: 'Нет доступа' }, { status: 401 });
}
try {
const data = await req.json();
const { type, room, date, time, name, game, contact, contactMethod, status } = data;
if (!type || !room || !date || !time || !name || !contact) {
return NextResponse.json(
{ error: 'Не все обязательные поля заполнены' },
{ status: 400 }
);
}
// Формируем datetime из date и time
const dateTime = `${date} ${time}:00`;
// Проверяем доступность слота перед созданием (если статус не cancelled)
if (status !== 'cancelled') {
const [existingBookings] = await bd.query(
`SELECT COUNT(*) as count FROM bookings
WHERE DATE(date_time) = ? AND time_slot = ? AND room = ? AND status != 'cancelled'`,
[date, time, room]
) as any[];
const bookedCount = existingBookings[0]?.count || 0;
// Получаем максимальную вместимость для типа комнаты
const ROOM_CAPACITY: Record<string, number> = {
'solo': 5,
'duo': 4,
'trio': 3,
'bootcamp 5': 2,
'bootcamp 6': 1,
};
const maxCapacity = ROOM_CAPACITY[room] || 0;
if (bookedCount >= maxCapacity) {
return NextResponse.json(
{ error: 'Этот временной слот уже занят' },
{ status: 409 }
);
}
}
// Создаем бронирование
const [result] = await bd.query(
`INSERT INTO bookings (type, room, date_time, time_slot, name, game, contact, contact_method, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[type, room, dateTime, time, name, game || null, contact, contactMethod || null, status || 'pending']
) as any[];
return NextResponse.json({
success: true,
id: result.insertId,
});
} catch (error) {
console.error('Ошибка при создании бронирования:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
// DELETE - удалить бронирование
export async function DELETE(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!verifyAdminToken(token)) {
return NextResponse.json({ error: 'Нет доступа' }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Необходим id' },
{ status: 400 }
);
}
await bd.query('DELETE FROM bookings WHERE id = ?', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Ошибка при удалении бронирования:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
import { verifyAdminToken } from '@/lib/verifyAdmin'
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value
const isAdmin = verifyAdminToken(token)
if (!isAdmin) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 })
}
try {
const [rows] = await bd.query(
'SELECT value, type FROM contacts'
)
return NextResponse.json(rows)
} catch {
return NextResponse.json({ error: 'Ошибка БД' }, { status: 500 })
}
}
export async function PUT(req: NextRequest) {
const token = req.cookies.get('token')?.value;
const isAdmin = verifyAdminToken(token);
if (!isAdmin) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 });
}
const data = await req.json();
const { value, type } = data;
if (!value || !type) {
return NextResponse.json({ error: 'value и type обязательны' }, { status: 400 });
}
try {
await bd.query(
`UPDATE contacts SET value = ? WHERE type = ?`,
[value, type]
);
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Ошибка при обновлении контакта' }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
import { verifyAdminToken } from '@/lib/verifyAdmin'
import type { ResultSetHeader, FieldPacket } from 'mysql2'
// GET — получить все записи
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const [rows] = await bd.query('SELECT * FROM faq ORDER BY id DESC')
return NextResponse.json(rows)
}
// POST — создать новую запись
export async function POST(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const { title, description } = await req.json()
if (!title || !description) {
return NextResponse.json({ error: 'Заполните все поля' }, { status: 400 })
}
const [result]: [ResultSetHeader, FieldPacket[]] = await bd.query(
'INSERT INTO faq (title, description) VALUES (?, ?)',
[title, description]
)
return NextResponse.json({ id: result.insertId, title, description })
}
// PUT — редактировать существующую
export async function PUT(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const { id, title, description } = await req.json()
if (!id || !title || !description) {
return NextResponse.json({ error: 'Заполните все поля' }, { status: 400 })
}
await bd.query('UPDATE faq SET title = ?, description = ? WHERE id = ?', [
title, description, id
])
return NextResponse.json({ success: true })
}
// DELETE — удалить запись
export async function DELETE(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID не указан' }, { status: 400 })
await bd.query('DELETE FROM faq WHERE id = ?', [id])
return NextResponse.json({ success: true })
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
import { verifyAdminToken } from '@/lib/verifyAdmin'
export async function DELETE(req: NextRequest) {
const token = req.cookies.get('token')?.value
const isAdmin = verifyAdminToken(token)
if (!isAdmin) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID не указан' }, { status: 400 })
try {
await bd.query('DELETE FROM games WHERE id = ?', [id])
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Ошибка при удалении' }, { status: 500 })
}
}
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value
const isAdmin = verifyAdminToken(token)
if (!isAdmin) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 })
}
try {
const [rows] = await bd.query('SELECT id, title, image_url, platform FROM games')
return NextResponse.json(rows)
} catch {
return NextResponse.json({ error: 'Ошибка БД' }, { status: 500 })
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken } from '@/lib/verifyAdmin';
import { bd } from '@/lib/bd';
import type { ResultSetHeader } from 'mysql2';
import fs from 'fs/promises';
import path from 'path';
export async function POST(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!verifyAdminToken(token)) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 });
}
const formData = await req.formData();
const title = formData.get('title');
const platform = formData.get('platform');
const image = formData.get('image') as File | null;
if (
typeof title !== 'string' ||
typeof platform !== 'string' ||
!image ||
typeof image !== 'object'
) {
return NextResponse.json({ error: 'Заполните все поля корректно' }, { status: 400 });
}
const bytes = await image.arrayBuffer();
const buffer = Buffer.from(bytes);
const fileName = `${Date.now()}-${image.name.replace(/\s+/g, '_')}`;
const uploadDir = path.join(process.cwd(), 'uploads/games');
const filePath = path.join(uploadDir, fileName);
try {
// создаём директорию если её нет
await fs.mkdir(uploadDir, { recursive: true });
// сохраняем файл
await fs.writeFile(filePath, buffer);
const [result] = await bd.query<ResultSetHeader>(
'INSERT INTO games (title, image_url, platform) VALUES (?, ?, ?)',
[title, fileName, platform]
);
return NextResponse.json({
id: result.insertId,
title,
platform,
image_url: fileName,
});
} catch (err) {
console.error('Ошибка при сохранении файла или в базе данных:', err);
return NextResponse.json({ error: 'Ошибка сервера' }, { status: 500 });
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
import { verifyAdminToken } from '@/lib/verifyAdmin'
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value
const isAdmin = verifyAdminToken(token)
if (!isAdmin) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 })
}
try {
const [rows] = await bd.query(
'SELECT game_type, room_type, day_type, hour_1, hour_3, hour_5, night FROM prices'
)
return NextResponse.json(rows)
} catch {
return NextResponse.json({ error: 'Ошибка БД' }, { status: 500 })
}
}
export async function PUT(req: NextRequest) {
const token = req.cookies.get('token')?.value
const isAdmin = verifyAdminToken(token)
if (!isAdmin) {
return NextResponse.json({ error: 'Доступ запрещён' }, { status: 401 })
}
const data = await req.json()
const {
game_type,
room_type,
day_type,
hour_1,
hour_3,
hour_5,
night,
} = data
if (!game_type || !room_type || !day_type) {
return NextResponse.json({ error: 'Ключевые поля отсутствуют' }, { status: 400 })
}
try {
await bd.query(
`UPDATE prices
SET hour_1 = ?, hour_3 = ?, hour_5 = ?, night = ?
WHERE game_type = ? AND room_type = ? AND day_type = ?`,
[hour_1, hour_3, hour_5, night, game_type, room_type, day_type]
)
return NextResponse.json({ success: true })
} catch (error) {
console.error(error)
return NextResponse.json({ error: 'Ошибка при обновлении' }, { status: 500 })
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
import { verifyAdminToken } from '@/lib/verifyAdmin'
export async function GET(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const [rows] = await bd.query('SELECT * FROM rooms')
return NextResponse.json(rows)
}
export async function POST(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const data = await req.json()
const fields = ['name', 'description', 'cpu', 'ram', 'gpu', 'mouse', 'keyboard', 'headphones', 'monitor', 'bracket', 'chair']
const values = fields.map(f => data[f])
await bd.query(
`INSERT INTO rooms (${fields.join(', ')}) VALUES (${fields.map(() => '?').join(', ')})`,
values
)
return NextResponse.json({ success: true })
}
export async function PUT(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const data = await req.json()
const fields = ['name', 'description', 'cpu', 'ram', 'gpu', 'mouse', 'keyboard', 'headphones', 'monitor', 'bracket', 'chair']
const values = fields.map(f => data[f])
values.push(data.id)
await bd.query(
`UPDATE rooms SET ${fields.map(f => `${f} = ?`).join(', ')} WHERE id = ?`,
values
)
return NextResponse.json({ success: true })
}
export async function DELETE(req: NextRequest) {
const token = req.cookies.get('token')?.value
if (!verifyAdminToken(token)) return NextResponse.json({ error: 'Нет доступа' }, { status: 401 })
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'Нет ID' }, { status: 400 })
await bd.query('DELETE FROM rooms WHERE id = ?', [id])
return NextResponse.json({ success: true })
}
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { bd } from '@/lib/bd';
// Количество компьютеров по типам комнат
const ROOM_CAPACITY: Record<string, number> = {
'solo': 5,
'duo': 4,
'trio': 3,
'bootcamp 5': 2,
'bootcamp 6': 1,
};
// Генерируем все часы дня (0-23)
function generateAllHours(): string[] {
return Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, '0');
return `${hour}:00`;
});
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const date = searchParams.get('date');
const room = searchParams.get('room');
if (!date || !room) {
return NextResponse.json(
{ error: 'Необходимы параметры date и room' },
{ status: 400 }
);
}
// Получаем все бронирования на эту дату и для этого типа комнаты
const [bookings] = await bd.query(
`SELECT time_slot, room FROM bookings
WHERE DATE(date_time) = ? AND room = ? AND status != 'cancelled'`,
[date, room]
) as any[];
// Подсчитываем количество бронирований на каждый час
const bookingsByHour: Record<string, number> = {};
bookings.forEach((booking: any) => {
const hour = booking.time_slot;
bookingsByHour[hour] = (bookingsByHour[hour] || 0) + 1;
});
// Получаем максимальное количество компьютеров для этого типа комнаты
const maxCapacity = ROOM_CAPACITY[room] || 0;
// Генерируем все часы и проверяем доступность
const allHours = generateAllHours();
const availableSlots = allHours.map(hour => {
const bookedCount = bookingsByHour[hour] || 0;
const isAvailable = bookedCount < maxCapacity;
return {
time: hour,
available: isAvailable,
booked: bookedCount,
total: maxCapacity,
};
});
return NextResponse.json({ slots: availableSlots });
} catch (error) {
console.error('Ошибка при получении доступных слотов:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server';
import { bd } from '@/lib/bd';
// POST - создать новое бронирование
export async function POST(req: NextRequest) {
try {
const data = await req.json();
const { type, room, date, time, name, game, contact, contactMethod } = data;
if (!type || !room || !date || !time || !name || !contact) {
return NextResponse.json(
{ error: 'Не все обязательные поля заполнены' },
{ status: 400 }
);
}
// Формируем datetime из date и time
const dateTime = `${date} ${time}:00`;
// Проверяем доступность слота перед созданием
const [existingBookings] = await bd.query(
`SELECT COUNT(*) as count FROM bookings
WHERE DATE(date_time) = ? AND time_slot = ? AND room = ? AND status != 'cancelled'`,
[date, time, room]
) as any[];
const bookedCount = existingBookings[0]?.count || 0;
// Получаем максимальную вместимость для типа комнаты
const ROOM_CAPACITY: Record<string, number> = {
'solo': 5,
'duo': 4,
'trio': 3,
'bootcamp 5': 2,
'bootcamp 6': 1,
};
const maxCapacity = ROOM_CAPACITY[room] || 0;
if (bookedCount >= maxCapacity) {
return NextResponse.json(
{ error: 'Этот временной слот уже занят' },
{ status: 409 }
);
}
// Создаем бронирование
const [result] = await bd.query(
`INSERT INTO bookings (type, room, date_time, time_slot, name, game, contact, contact_method, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', NOW())`,
[type, room, dateTime, time, name, game || null, contact, contactMethod || null]
) as any[];
return NextResponse.json({
success: true,
id: result.insertId,
});
} catch (error) {
console.error('Ошибка при создании бронирования:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
// GET - получить все бронирования (для админки)
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const date = searchParams.get('date');
const room = searchParams.get('room');
let query = 'SELECT * FROM bookings WHERE 1=1';
const params: any[] = [];
if (date) {
query += ' AND DATE(date_time) = ?';
params.push(date);
}
if (room) {
query += ' AND room = ?';
params.push(room);
}
query += ' ORDER BY date_time DESC, time_slot ASC';
const [bookings] = await bd.query(query, params);
return NextResponse.json({ bookings });
} catch (error) {
console.error('Ошибка при получении бронирований:', error);
return NextResponse.json(
{ error: 'Ошибка сервера' },
{ status: 500 }
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
export async function GET() {
const [rows] = await bd.query('SELECT * FROM contacts')
return NextResponse.json(rows)
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server'
import { bd } from '@/lib/bd'
export async function GET() {
const [rows] = await bd.query('SELECT * FROM faq ORDER BY id DESC')
return NextResponse.json(rows)
}
+16
View File
@@ -0,0 +1,16 @@
import {bd} from "@/lib/bd";
import {NextResponse} from "next/server";
export async function GET(request: Request){
const {searchParams} = new URL(request.url)
const platform = searchParams.get('platform') || 'pc';
try {
const [rows] = await bd.query(
`SELECT id, title, image_url, platform FROM games WHERE platform = ?`,
[platform]
)
return NextResponse.json(rows)
}catch (error){
console.error("DB error", error)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import {bd} from "@/lib/bd";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const gameType = searchParams.get('gameType');
const roomType = searchParams.get('roomType');
const [rows] = await bd.execute(
'SELECT day_type, hour_1, hour_3, hour_5, night FROM prices WHERE game_type = ? AND room_type = ?',
[gameType, roomType]
);
return NextResponse.json(rows);
}
+11
View File
@@ -0,0 +1,11 @@
import {bd} from '@/lib/bd';
import {NextResponse} from "next/server";
export async function GET(){
try {
const [rows] = await bd.query('SELECT * FROM rooms');
return NextResponse.json(rows);
}catch (error){
return NextResponse.json({message: 'Ошибка запроса', error: error}, {status:500})
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const data = await req.json();
const token = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!token || !chatId) {
return NextResponse.json({ error: 'Missing env vars' }, { status: 500 });
}
console.log('gr')
const text = `
<b>📬 Новая заявка</b>
🎮 Действие: ${data.type || '—'}
🖥 Кабина: ${data.room || '—'}
📅 Дата: ${data.date || '—'}
⏰ Время: ${data.time || '—'}
🧑 Имя: ${data.name || '—'}
🎮 Игра: ${data.game || '—'}
📞 Контакт: ${data.contact || '—'}
💬 Связь: ${data.contactMethod || '—'}
`;
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
}),
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to send' }, { status: 500 });
}
return NextResponse.json({ success: true });
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import fs from 'fs/promises';
export async function GET(req: NextRequest): Promise<NextResponse> {
const pathname = req.nextUrl.pathname; // /api/uploads/games/image.png
const relativePath = pathname.replace(/^\/api\/uploads\//, ''); // games/image.png
const filePath = path.join(process.cwd(), 'uploads', ...relativePath.split('/'));
try {
await fs.access(filePath);
const fileBuffer = await fs.readFile(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
const contentTypes: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': contentTypes[ext] || 'application/octet-stream',
},
});
} catch (err) {
console.error('Файл не найден:', err);
return new NextResponse('Not found', { status: 404 });
}
}
+15
View File
@@ -0,0 +1,15 @@
@import "bootstrap/dist/css/bootstrap.min.css";
@font-face {
font-family: 'TradeGothic';
src: url('/fonts/TradeGothicPro.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
body {
background-color: #0E0E17;
font-family: 'TradeGothic', sans-serif;
text-transform: uppercase;
color: #fff;
}
+81
View File
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import "./globals.css";
import "../styles/reset.css";
import React from "react";
import StartupLoader from "@/components/StartupLoader";
import Script from "next/script";
export const viewport = {
themeColor: "#00ff90",
colorScheme: "dark",
};
export const metadata: Metadata = {
metadataBase: new URL("https://strike-arena.kz"),
title: "Strike Arena — Киберклуб нового уровня",
description: "Киберклуб Strike Arena: современные ПК и PS5, VIP-кабины, быстрая бронь, турниры и вознаграждения. Бронируй онлайн и выигрывай чаще.",
keywords: ["Strike Arena", "киберклуб", "компьютерный клуб", "игровые залы", "бронь ПК", "PlayStation", "турниры", "игры", "e-sports"],
applicationName: "Strike Arena",
generator: "Next.js",
manifest: "/site.webmanifest",
authors: [{ name: "Strike Arena Team", url: "https://strike-arena.kz" }],
creator: "Strike Arena",
publisher: "Strike Arena",
openGraph: {
title: "Strike Arena — Игровой клуб нового поколения",
description: "Современный киберклуб с бронью мест, турнирами, призами и VIP-комфортом.",
url: "https://strike-arena.kz",
siteName: "Strike Arena",
images: [
{
url: "/og-image.jpg",
width: 1200,
height: 630,
alt: "Strike Arena — киберклуб нового уровня",
},
],
locale: "ru_RU",
type: "website",
},
icons: {
icon: [
{ url: "/favicons/favicon.ico", sizes: "any" },
{ url: "/favicons/apple-touch-icon.png", sizes: "180x180" },
{ url: "/favicons/android-chrome-192x192.png", sizes: "192x192" },
{ url: "/favicons/android-chrome-512x512.png", sizes: "512x512" },
],
},
twitter: {
card: "summary_large_image",
title: "Strike Arena — Киберклуб нового уровня",
description: "Современный клуб с лучшими условиями для геймеров. Бронируй — играй — побеждай.",
images: ["/og-image.jpg"],
},
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ru">
<body>
{children}
<StartupLoader />
<Script
id="binotel-getcall"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(d, w, s) {
var widgetHash = 'j2he8ic2huirt42fsxdj', gcw = d.createElement(s);
gcw.type = 'text/javascript'; gcw.async = true;
gcw.src = '//widgets.binotel.com/getcall/widgets/' + widgetHash + '.js';
var sn = d.getElementsByTagName(s)[0]; sn.parentNode.insertBefore(gcw, sn);
})(document, window, 'script');
`,
}}
/>
</body>
</html>
);
}
+704
View File
@@ -0,0 +1,704 @@
'use client'
import styled from "styled-components";
import {
blackColor,
darkGreenColor,
inactiveBorderColor,
inactiveTextColor,
lightGreenColor,
whiteColor
} from "@/styles/colors";
// General
export const MainPage = styled.div`
width: 100%;
overflow: hidden;
`
export const Section = styled.section`
max-width: 1220px;
position: relative;
margin: 220px auto 0;
-webkit-transform: translateZ(0);
will-change: transform;
h2{
text-align: center;
font-size: 40px;
span{
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@media(max-width: 1400px) {
font-size: 32px;
width: 80%;
margin: 0 auto;
}
@media (max-width: 1060px) {
font-size: clamp(24px, 5vw,36px);
width: 80%;
margin: 0 auto;
}
}
&.roomSection::after{
content: "";
width:300px;
height: 200px;
position: absolute;
top: 300px;
right: -400px;
border-radius: 50%;
background: ${lightGreenColor};
filter: blur(120px);
}
`
export const PriceHeader_btn = styled.button`
font-size: 64px;
text-transform: uppercase;
color: ${inactiveTextColor};
margin-left: 170px;
position: relative;
&::after{
content: "";
left: 50%;
transform: translateX(-50%);
bottom: -18px;
background: ${inactiveBorderColor};
position: absolute;
width: 120%;
height: 7px;
}
&.active{
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
&.active::after{
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
}
}
@media (max-width: 1400px) {
font-size: 52px;
margin-left: 100px;
line-height: 1.1;
&::after{
bottom: -16px;
height: 6px;
}
}
@media (max-width: 1300px) {
margin-left: 100px;
}
@media (max-width: 1080px) {
margin-left: 0;
font-size: 40px;
}
@media (max-width: 720px) {
font-size: 32px;
&::after{
bottom: -14px;
height: 6px;
width: 100%;
}
}
@media (max-width: 420px) {
width: 100%;
&::after{
width: 100%;
}
}
`;
// start
export const StartBlock = styled.section`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
max-width: 1920px;
padding: 0 60px;
margin: 0 auto;
position: relative;
width: 100%;
height: 70vh;
&::after{
content: "";
position: absolute;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 100vw;
height: 1144px;
background-image: url("/images/startBg.png");
background-repeat: no-repeat;
background-size: cover;
background-position: center;
z-index: -10;
}
img{
position: absolute;
z-index: -1;
}
img:nth-child(1){
left: 100px;
}
img:nth-child(4){
right: 150px;
}
h1{
font-size: 64px;
line-height: 1;
text-align: center;
max-width: 648px;
margin-bottom: 40px;
span {
background: linear-gradient(
to right,
${lightGreenColor} 0%,
${lightGreenColor} 40%,
${darkGreenColor} 110%,
${darkGreenColor} 140%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
@media (max-width: 1800px) {
img:nth-child(1){
left: 50px;
}
img:nth-child(4){
right: 75px;
}
}
@media (max-width: 1600px) {
img:nth-child(4){
right: 40px;
width: 362px;
}
img:nth-child(1){
left: 10px;
width: 402px;
}
}
@media (max-width: 1400px) {
height: 75vh;
img:nth-child(4){
width: 360px;
}
img:nth-child(1){
width: 380px;
}
h1{
font-size: 50px;
max-width: 508px;
margin-bottom: 24px;
}
button{
font-size: 32px;
padding: 16px 38px;
}
}
@media (max-width: 1300px) {
img:nth-child(4){
right: 10px;
}
img:nth-child(1){
left: 0;
}
}
@media (max-width: 1180px) {
img:nth-child(4){
width: 280px;
right: 10px;
}
img:nth-child(1){
width: 340px;
left: -50px;
}
h1{
font-size: 50px;
max-width: 508px;
margin-bottom: 24px;
}
button{
font-size: 32px;
}
}
@media (max-width: 1040px) {
height: 80vh;
img:nth-child(4){
width: 280px;
bottom: -20%;
right: 10%;
}
img:nth-child(1){
width: 340px;
bottom: -20%;
left: 10%;
}
}
@media (max-width: 850px) {
img:nth-child(4){
width: 280px;
bottom: -20%;
right: 4%;
}
img:nth-child(1){
width: 340px;
bottom: -20%;
left: 4%;
}
}
@media (max-width: 600px) {
padding: 0 20px;
margin-top: 60px;
justify-content: start;
height: 50vh;
img:nth-child(4){
width: 240px;
bottom: -26%;
right: 4%;
}
img:nth-child(1){
width: 280px;
bottom: -26%;
left: 4%;
}
h1{
font-size: 40px;
max-width: 400px;
}
button{
font-size: 26px;
padding: 12px 32px;
}
}
@media (max-width: 520px) {
height: 40vh;
h1{
font-size: 30px;
max-width: 300px;
}
img:nth-child(4){
width: 220px;
bottom: -34%;
right: 5%;
}
img:nth-child(1){
width: 220px;
bottom: -34%;
left: 5%;
}
button{
font-size: 26px;
padding: 12px 32px;
}
}
@media (max-width: 450px) {
img:nth-child(4){
width: 220px;
bottom: -34%;
right: 2%;
}
img:nth-child(1){
width: 220px;
bottom: -34%;
left: 2%;
}
}
@media (max-width: 418px) {
img:nth-child(4){
width: 200px;
bottom: -40%;
right: 2%;
}
img:nth-child(1){
width: 200px;
bottom: -40%;
left: -4%;
}
h1{
font-size: 30px;
max-width: 300px;
}
button{
font-size: 26px;
padding: 12px 32px;
}
}
@media (max-width: 360px) {
img:nth-child(4){
width: 180px;
bottom: -24%;
right: 2%;
}
img:nth-child(1){
width: 180px;
bottom: -24%;
left: -4%;
}
}
@media (max-height: 740px) {
img:nth-child(4){
bottom: -44%;
}
img:nth-child(1){
bottom: -44%;
}
}
@media (max-height: 690px) {
img:nth-child(4){
bottom: -56%;
}
img:nth-child(1){
bottom: -56%;
}
}
`
// App
export const AppSection = styled.section`
background: ${blackColor};
display: flex;
justify-content: center;
align-items: center;
width: 90%;
margin: 140px auto 80px;
border-radius: 40px;
position: relative;
&::after{
content: "";
position: absolute;
bottom: -14%;
left: -20%;
background: ${lightGreenColor};
width: 500px;
height: 400px;
border-radius: 30%;
filter: blur(200px);
opacity: .5;
z-index: -1;
}
@media (max-width: 1250px) {
padding: 60px 0;
.phoneApp{
display: none;
}
}
`
export const AppText = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
width: 706px;
h2{
font-size: 40px;
margin: 20px 0 24px;
}
p{
font-size: 24px;
margin-bottom: 24px;
span{
color: red;
}
}
div{
display: flex;
justify-content: center;
align-items: center;
gap: 40px;
}
button{
cursor: auto;
}
@media (max-width: 850px) {
width: 600px;
}
@media (max-width: 650px) {
width: 90%;
h2{
font-size: 30px;
}
p{
font-size: 18px;
}
div{
gap: 10px;
img{
width: 160px;
}
}
}
@media (max-width: 450px) {
h2{
font-size: 30px;
}
p{
font-size: 18px;
}
div{
flex-direction: column;
gap: 0;
}
button{
font-size: 20px;
padding: 8px 28px;
}
}
`
// faq
export const FaqAccordion = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
&::after{
content: "";
position: absolute;
bottom: 0;
left: 0;
background: ${lightGreenColor};
width: 500px;
height: 500px;
border-radius: 50%;
filter: blur(200px);
opacity: .25;
z-index: -1;
}
&::before{
content: "";
position: absolute;
top: 10%;
right: -33%;
transform: rotate(-30deg);
background: ${lightGreenColor};
width: 900px;
height: 500px;
border-radius: 40%;
filter: blur(200px);
opacity: .6;
z-index: -1;
}
`
export const FaqTitle = styled.div`
text-align: center;
h2{
font-size: 40px;
}
h3{
font-size: 20px;
margin: 12px 0 50px;
}
`
//footer
export const HeaderPhone = styled.div`
display: flex;
align-items: start;
flex-direction: column;
justify-content: start;
margin-left: 33px;
h4{
font-size: 14px;
margin-top: 5px;
cursor: pointer;
}
p{
font-size: 20px;
line-height: 1;
}
`
export const HeaderBlock = styled.div`
display: flex;
align-items: center;
justify-content: center;
.logo{
margin-right: 56px;
}
.gis2Link{
margin-right: 15px;
}
.tgLink{
margin: 0 12px 0 8px;
}
.footerPhone{
h4{
text-align: center;
font-size: 20px;
}
}
`
export const Footer = styled.footer`
display: flex;
max-width: 1500px;
margin: 200px auto 0;
height: 140px;
justify-content: center;
align-items: center;
gap: 218px;
position: relative;
.policy{
text-transform: uppercase;
text-align: center;
color:${whiteColor};
font-size: 20px;
}
.inst{
margin-right: 8px;
}
&::after{
content: "";
position: absolute;
bottom: -66%;
left: 24%;
background: ${lightGreenColor};
width: 600px;
height: 90px;
filter: blur(80px);
border-radius: 20%;
z-index: -1;
}
&::before{
content: "";
position: absolute;
bottom: -90%;
left: -12%;
background: ${lightGreenColor};
width: 200px;
height: 120px;
filter: blur(80px);
border-radius: 20%;
z-index: -1;
}
@media (max-width: 1260px) {
width: 90%;
margin: 200px auto 0;
justify-content: space-between;
gap: 0;
}
@media (max-width: 920px) {
flex-wrap: wrap;
justify-content: center;
column-gap: 64px;
height: max-content;
.footerBlock{
display: flex;
justify-content: center;
width: 100%;
column-gap: 64px;
margin: 32px 0 24px;
}
}
@media (max-width: 720px) {
margin: 160px auto 0;
}
@media (max-width: 500px) {
justify-content: center;
margin-top: 100px;
.footerBlock{
flex-direction: column;
gap: 20px;
margin: 20px 0 20px;
}
.logo{
order: 1;
}
.footerBlock{
order: 2;
}
.footerPhone{
margin-left:0;
align-items: center;
}
.policy{
order: 3;
margin-bottom: 26px;
}
}
`
// Policy
export const PolicyContent = styled.div`
text-transform: none;
font-weight: normal;
max-width: 994px;
margin: 0 auto 150px;
font-size: 32px;
line-height: 1.4;
position: relative;
padding: 0 20px;
h1 {
margin: 70px 0 55px;
font-size: 64px;
text-align: center;
line-height: 1;
}
&::before {
content: "";
position: absolute;
bottom: -10%;
right: -60%;
background: ${lightGreenColor};
width: 500px;
height: 500px;
opacity: 0.6;
filter: blur(200px);
border-radius: 20%;
z-index: -1;
}
&::after {
content: "";
position: absolute;
top: -20%;
left: -10%;
background: ${lightGreenColor};
width: 500px;
height: 200px;
opacity: 0.6;
filter: blur(200px);
border-radius: 20%;
z-index: -4;
}
@media (max-width: 768px) {
font-size: 24px;
h1 {
font-size: 48px;
}
}
@media (max-width: 480px) {
font-size: 20px;
margin-bottom: 100px;
h1 {
font-size: 38px;
margin: 50px 0 40px;
}
}
@media (max-width: 370px) {
font-size: 18px;
h1 {
font-size: 32px;
}
&::before,
&::after {
display: none;
}
}
`
+182
View File
@@ -0,0 +1,182 @@
'use client'
import Link from "next/link";
import Image from "next/image";
import {
AppSection, AppText, FaqAccordion, FaqTitle, Footer,
MainPage,
HeaderBlock, HeaderPhone,
Section,
StartBlock
} from "@/app/main.styled";
import StyledButton from "@/components/StyledButton";
import RoomList from "@/components/RoomsList";
import PriceList from "@/components/PriceList";
import FeaturesList from "@/components/FeaturesList";
import GameList from "@/components/GameList";
import React, {useEffect, useState} from "react";
import Accordion from "@/components/Accordion";
import HeaderPage from "@/components/HeaderPage";
import {useBronStore} from "@/store/useBronStore";
import BronModal from "@/components/BronModal";
import { motion } from "framer-motion";
type Faq = {
id: number
title: string
description: string
}
type Contacts = {
id: number
type: string
value: string
}
export default function Home() {
const open = useBronStore((s) => s.open);
const [faqs, setFaqs] = useState<Faq[]>([])
const [contacts, setContacts] = useState<Contacts[]>([])
const inst = contacts.find((c)=> c.type ==='inst');
const phone = contacts.find((c)=> c.type ==='phone');
const tg = contacts.find((c)=> c.type ==='tg');
const gis2 = contacts.find((c)=> c.type ==='gis2');
const whatsapp = contacts.find((c)=> c.type ==='whatsapp');
useEffect(() => {
async function fetchFaq() {
const res = await fetch(`/api/faq`);
const data = await res.json();
setFaqs(data);
}
async function fetchContact() {
const res = await fetch(`/api/contacts`);
const data = await res.json();
setContacts(data);
}
fetchFaq();
fetchContact();
}, []);
return (
<MainPage>
<HeaderPage whatsapp={whatsapp?.value} tg={tg?.value} phone={phone?.value} inst={inst?.value} gis2={gis2?.value} />
<main>
<StartBlock>
<Image src={'/images/startLeft.png'} alt={'startLeft'} width={482} height={440} />
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5 }}
>
Выигрывай чаще и получай удовольствие <span>от компьютерных игр</span>
</motion.h1>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.8 }}
>
<StyledButton onClick={() => open('start')} label={'Забронировать место'} padding={'14px 46px'} fontSize={'42px'} />
</motion.div>
<Image src={'/images/startRight.png'} alt={'startRight'} width={422} height={410} quality={100} />
</StartBlock>
<Section className={'roomSection'}>
<h2>В нашем клубе мы предлагаем только <span>современное и надёжное</span> оборудование, которое обеспечит вам <span>комфортную игру</span></h2>
<RoomList />
</Section>
<Section>
<PriceList />
</Section>
<Section>
<FeaturesList />
</Section>
<Section>
<GameList tg={tg?.value} whatsapp={whatsapp?.value} />
</Section>
<AppSection>
<AppText>
<StyledButton label={'Приложение'} padding={'12px 38px'} fontSize={'24px'} />
<h2>Загрузи и бронируй заранее.</h2>
<p>Следи за событиями, прокачивай профиль и получай вознаграждения. Пополнение счёта c <span>Kaspi</span> в пару кликов.</p>
<div>
<Link href={'https://play.google.com/store/apps/details?id=com.f5computers.langame_aggregator'}>
<Image src={'/icons/appGooglePlay.png'} alt={'google play'} width={220} height={84} loading={"lazy"} />
</Link>
<Link href={'https://apps.apple.com/us/app/langame/id1642484175?l=ru'}>
<Image src={'/icons/appAppStore.png'} alt={'appStore'} width={235} height={84} loading={"lazy"} />
</Link>
</div>
</AppText>
<Image className={'phoneApp'} src={'/images/phone.png'} alt={'phone'} width={412} height={414} loading={"lazy"} />
</AppSection>
<FaqAccordion>
<FaqTitle>
<h2>FAQ</h2>
<h3>Часто задаваемые вопросы</h3>
</FaqTitle>
{faqs.map((faqBlock)=>(
<Accordion key={faqBlock.id} title={faqBlock.title}>
{faqBlock.description}
</Accordion>
))}
</FaqAccordion>
</main>
<Footer>
<Link href='/' className={'logo'}>
<Image src={'/icons/logo.png'} alt={'logo'} width={226} height={60} loading={'lazy'} />
</Link>
<Link href='/policy' className={'policy'}>
Политика<br/> конфиденциальности
</Link>
<HeaderBlock className={'footerBlock'}>
<div>{inst ? (
<Link href={inst.value} target="_blank">
<Image src="/icons/inst.png" alt="inst" width={60} height={60} loading="lazy" />
</Link>
) : (
<Image src="/icons/inst.png" alt="inst" width={60} height={60} loading="lazy" />
)}
{whatsapp ? (
<Link href={whatsapp.value} target="_blank">
<Image src="/icons/whatsapp.png" alt="whatsapp" width={58} height={58} loading="lazy" />
</Link>
) : (
<Image src="/icons/whatsapp.png" alt="whatsapp" width={58} height={58} loading="lazy" />
)}
{tg ? (
<Link href={tg.value} className="tgLink" target="_blank">
<Image src="/icons/tg.png" alt="tg" width={48} height={48} loading="lazy" />
</Link>
) : (
<Image src="/icons/tg.png" alt="tg" width={48} height={48} loading="lazy" />
)}
{gis2 ? (
<Link href={gis2.value} className="gis2Link headerImage" target="_blank">
<Image src="/icons/location.svg" alt="geo" width={37} height={50} loading="lazy" />
</Link>
) : (
<Link href={'/'} className="gis2Link headerImage" target="_blank">
<Image src="/icons/location.svg" alt="geo" width={37} height={50} loading="lazy" />
</Link>
)}</div>
<HeaderPhone className={'footerPhone'}>
<p style={{ marginBottom: 0 }}>{phone?.value}</p>
<h4 onClick={() => open('zayvka-game')}>Заказать звонок</h4>
</HeaderPhone>
</HeaderBlock>
</Footer>
<BronModal />
</MainPage>
);
};
+54
View File
@@ -0,0 +1,54 @@
'use client'
import {MainPage, PolicyContent} from "@/app/main.styled";
import HeaderPage from "@/components/HeaderPage";
import React, {useEffect, useState} from "react";
type Contacts = {
id: number
type: string
value: string
}
export default function AboutPage() {
const [contacts, setContacts] = useState<Contacts[]>([])
const phone = contacts.find((c)=> c.type ==='phone');
const inst = contacts.find((c)=> c.type ==='inst');
const tg = contacts.find((c)=> c.type ==='tg');
const gis2 = contacts.find((c)=> c.type ==='gis2');
const whatsapp = contacts.find((c)=> c.type ==='whatsapp');
useEffect(() => {
async function fetchContact() {
const res = await fetch(`/api/contacts`);
const data = await res.json();
setContacts(data);
}
fetchContact();
}, []);
return (
<MainPage>
<HeaderPage whatsapp={whatsapp?.value} tg={tg?.value} phone={phone?.value} inst={inst?.value} gis2={gis2?.value} />
<PolicyContent>
<h1>Политика конфиденциальности</h1>
<p>1. Общие положения Данная Политика конфиденциальности (далее &quot;Политика&quot;) определяет порядок
обработки и защиты персональных данных посетителей сайта и клиентов Компьютерного клуба Strike
Arena, принадлежащего ИП Медина. Информация о организации: ИП Медина Адрес: г. Астана, улица
Сығанак, 54, 120 БИН: 930805402161 Банк: АО «Kaspi Bank» КБе: 19 БИК: CASPKZKA Номер счёта:
KZ77722S000034244500 <br/>
2. Какие данные собираются<br/>Персональные данные: ФИО, телефон.<br/>Технические
данные: IP-адрес, тип устройства, cookies.<br/>Данные о посещении сайта: запись взаимодействий
(страницы, поведение). <br/>
3. Цели сбора данных<br/>Для бронирования времени в клубе.<br/>Для улучшения качества
оказываемых услуг. <br/>
4. Передача данных третьим лицам Передача данных третьим лицам возможна только в
целях обработки платежей. Данные не продаются и не передаются другим третьим лицам. <br/>
5. Защита персональных данных Мы принимаем все разумные меры для защиты данных, однако передача информации
через интернет не может быть абсолютно безопасной. <br/>
6. Права пользователейПраво на доступ к своим
данным.Право на изменение и удаление личных данных.<br/>
7. Контактные данные Компьютерный клуб Strike<br/>
Arena Телефон: +7-775-260-85-59 Email: strikearena.astana@gmail.com</p>
</PolicyContent>
</MainPage>
);
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
interface AccordionProps {
title: string;
children: React.ReactNode;
}
const AccordionContainer = styled.div`
padding: 30px 62px;
background: #15241E;
border-radius: 20px;
width: 878px;
margin-bottom: 25px;
cursor: pointer;
@media (max-width: 970px) {
width: 90%;
padding: 25px 40px;
}
@media (max-width: 750px) {
padding: 20px 25px;
border-radius: 15px;
margin-bottom: 15px;
}
@media (max-width: 480px) {
padding: 15px 20px;
width: 95%;
margin-bottom: 12px;
}
`;
const AccordionTitle = styled.div`
font-size: 40px;
text-transform: uppercase;
display: flex;
justify-content: space-between;
line-height: 1;
width: 100%;
gap: 14px;
align-items: center;
text-align: start;
h3 {
max-width: 580px;
margin: 0;
}
@media (max-width: 970px) {
font-size: 32px;
gap: 12px;
}
@media (max-width: 750px) {
font-size: 28px;
gap: 10px;
}
@media (max-width: 480px) {
font-size: 22px;
gap: 8px;
flex-direction: row;
justify-content: space-between;
h3 {
flex: 1;
}
}
`;
const AccordionContentWrapper = styled.div<{ $maxHeight: number }>`
overflow: hidden;
transition: max-height 0.4s ease;
max-height: ${({ $maxHeight }) => $maxHeight}px;
`;
const AccordionContent = styled.div.attrs({})<{ $isOpen: boolean }>`
opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)};
transform: ${({ $isOpen }) => ($isOpen ? 'translateY(0)' : 'translateY(-10px)')};
transition: opacity 0.3s ease, transform 0.3s ease;
p {
font-size: 20px;
text-align: start;
width: 100%;
margin-top: 14px;
text-transform: none;
max-width: 520px;
}
@media (max-width: 750px) {
p {
font-size: 18px;
margin-top: 12px;
}
}
@media (max-width: 480px) {
p {
font-size: 16px;
margin-top: 10px;
line-height: 1.4;
}
}
`;
const PlusIcon = styled.span<{ $isOpen: boolean }>`
transition: transform 0.3s ease;
font-size: 40px;
transform: ${({ $isOpen }) => ($isOpen ? 'rotate(45deg)' : 'rotate(0)')};
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 750px) {
font-size: 32px;
}
@media (max-width: 480px) {
font-size: 24px;
}
`;
const Accordion = ({ title, children }: AccordionProps) => {
const [isOpen, setIsOpen] = useState(false);
const [height, setHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
setHeight(isOpen ? contentRef.current.scrollHeight+20 : 0);
}
}, [isOpen]);
return (
<AccordionContainer onClick={() => setIsOpen(!isOpen)}>
<AccordionTitle>
<h3>{title}</h3>
<PlusIcon $isOpen={isOpen}>+</PlusIcon>
</AccordionTitle>
<AccordionContentWrapper $maxHeight={height}>
<AccordionContent $isOpen={isOpen} ref={contentRef}>
<p>{children}</p>
</AccordionContent>
</AccordionContentWrapper>
</AccordionContainer>
);
};
export default Accordion;

Some files were not shown because too many files have changed in this diff Show More