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