first
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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. Общие положения Данная Политика конфиденциальности (далее — "Политика") определяет порядок
|
||||
обработки и защиты персональных данных посетителей сайта и клиентов Компьютерного клуба 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user