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>
);
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
interface AccordionProps {
title: string;
children: React.ReactNode;
}
const AccordionContainer = styled.div`
padding: 30px 62px;
background: #15241E;
border-radius: 20px;
width: 878px;
margin-bottom: 25px;
cursor: pointer;
@media (max-width: 970px) {
width: 90%;
padding: 25px 40px;
}
@media (max-width: 750px) {
padding: 20px 25px;
border-radius: 15px;
margin-bottom: 15px;
}
@media (max-width: 480px) {
padding: 15px 20px;
width: 95%;
margin-bottom: 12px;
}
`;
const AccordionTitle = styled.div`
font-size: 40px;
text-transform: uppercase;
display: flex;
justify-content: space-between;
line-height: 1;
width: 100%;
gap: 14px;
align-items: center;
text-align: start;
h3 {
max-width: 580px;
margin: 0;
}
@media (max-width: 970px) {
font-size: 32px;
gap: 12px;
}
@media (max-width: 750px) {
font-size: 28px;
gap: 10px;
}
@media (max-width: 480px) {
font-size: 22px;
gap: 8px;
flex-direction: row;
justify-content: space-between;
h3 {
flex: 1;
}
}
`;
const AccordionContentWrapper = styled.div<{ $maxHeight: number }>`
overflow: hidden;
transition: max-height 0.4s ease;
max-height: ${({ $maxHeight }) => $maxHeight}px;
`;
const AccordionContent = styled.div.attrs({})<{ $isOpen: boolean }>`
opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)};
transform: ${({ $isOpen }) => ($isOpen ? 'translateY(0)' : 'translateY(-10px)')};
transition: opacity 0.3s ease, transform 0.3s ease;
p {
font-size: 20px;
text-align: start;
width: 100%;
margin-top: 14px;
text-transform: none;
max-width: 520px;
}
@media (max-width: 750px) {
p {
font-size: 18px;
margin-top: 12px;
}
}
@media (max-width: 480px) {
p {
font-size: 16px;
margin-top: 10px;
line-height: 1.4;
}
}
`;
const PlusIcon = styled.span<{ $isOpen: boolean }>`
transition: transform 0.3s ease;
font-size: 40px;
transform: ${({ $isOpen }) => ($isOpen ? 'rotate(45deg)' : 'rotate(0)')};
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 750px) {
font-size: 32px;
}
@media (max-width: 480px) {
font-size: 24px;
}
`;
const Accordion = ({ title, children }: AccordionProps) => {
const [isOpen, setIsOpen] = useState(false);
const [height, setHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
setHeight(isOpen ? contentRef.current.scrollHeight+20 : 0);
}
}, [isOpen]);
return (
<AccordionContainer onClick={() => setIsOpen(!isOpen)}>
<AccordionTitle>
<h3>{title}</h3>
<PlusIcon $isOpen={isOpen}>+</PlusIcon>
</AccordionTitle>
<AccordionContentWrapper $maxHeight={height}>
<AccordionContent $isOpen={isOpen} ref={contentRef}>
<p>{children}</p>
</AccordionContent>
</AccordionContentWrapper>
</AccordionContainer>
);
};
export default Accordion;
+48
View File
@@ -0,0 +1,48 @@
'use client'
import React from 'react';
import Link from "next/link";
import { usePathname } from 'next/navigation';
import {
AdminHeaderStyled,
AdminHeaderContent,
AdminLogo,
AdminNav,
AdminNavLink
} from "@/styles/admin.styled";
const AdminHeader = () => {
const pathname = usePathname();
const navItems = [
{ href: "/admin", label: "Бронирования" },
{ href: "/admin/contacts", label: "Контакты" },
{ href: "/admin/hardware", label: "Железо" },
{ href: "/admin/price", label: "Прайс" },
{ href: "/admin/games", label: "Игры" },
{ href: "/admin/faq", label: "FAQ" },
];
return (
<AdminHeaderStyled>
<AdminHeaderContent>
<AdminLogo href="/admin">STRIKE ARENA</AdminLogo>
<AdminNav>
{navItems.map((item) => (
<li key={item.href}>
<AdminNavLink
href={item.href}
$active={pathname === item.href}
as={Link}
>
{item.label}
</AdminNavLink>
</li>
))}
</AdminNav>
</AdminHeaderContent>
</AdminHeaderStyled>
);
};
export default AdminHeader;
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { motion } from "framer-motion";
import React from "react";
const containerVariants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.8,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 100 },
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" } },
};
export default function AnimatedSection({ children }: { children: React.ReactNode }) {
return (
<motion.section
variants={containerVariants}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.4 }}
>
{React.Children.map(children, (child) => (
<motion.div variants={itemVariants}>{child}</motion.div>
))}
</motion.section>
);
}
+640
View File
@@ -0,0 +1,640 @@
import React, { useEffect, useState } from 'react';
import { useBronStore } from '@/store/useBronStore';
import StyledButton from "@/components/StyledButton";
import Image from "next/image";
import {
ModalWrapper,
Overlay,
CloseButton,
SectionTitle,
Input,
ChoiceBtn,
TwoColumns,
TitleModal,
StepsModal,
BackBtn,
BronBtn,
MaskedInput, InputLabel,
TimeSlotGrid,
TimeSlotBtn
} from "@/styles/modal.styled";
import {gradient} from "@/styles/colors";
export default function BronModal() {
const today = new Date().toISOString().split('T')[0];
const { isOpen, screen, close, goTo, formData, setFormData } = useBronStore();
const [isVisible, setIsVisible] = useState(false);
const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
const [selectedDate, setSelectedDate] = useState<string>(today);
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const [availableSlots, setAvailableSlots] = useState<Array<{time: string, available: boolean, booked: number, total: number}>>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [choice, setChoice] = useState<string|null>(null)
const options = [
{label:"Играть на пк", value:'pc'},
{label:"Играть на ps", value:'ps'},
{label:"Смотреть трансляции", value:'tv'},
{label:"Пока не определился", value:'zayvka'},
]
const [choiceRoom, setChoiceRoom] = useState<string|null>(null)
const rooms = [
{title:"solo", label:"VIP кабины на одного игрока"},
{title:"duo", label:"vip кабины на двоих игроков"},
{title:"trio", label:"vip кабины на три игрока"},
{title:"bootcamp 5", label:"vip кабина на пять игроков"},
{title:"bootcamp 6", label:"vip кабина на шесть игроков"},
{title:"Пока не определился", label:""},
]
const [transRoom, setTransRoom] = useState<string|null>(null)
const transRooms = [
{title:"PS Big vip", label:"Большие VIP кабины. Компанией до 8 человек"},
{title:"PS Medium", label:"Средние VIP кабины. Компанией до 4 человек"},
{title:"PS лаундж", label:"PS зона в общем холле. На 2-4 человека"},
]
const contactOptions: { type: "telegram" | "whatsapp" | "phone"; icon: string }[] = [
{ type: 'telegram', icon: '/icons/tg.png' },
{ type: 'whatsapp', icon: '/icons/whatsapp.png' },
{ type: 'phone', icon: '/icons/phone.png' },
];
const fetchAvailableSlots = async (date: string, room: string) => {
if (!date || !room) return;
setLoadingSlots(true);
try {
const res = await fetch(`/api/bookings/available-slots?date=${date}&room=${room}`);
if (res.ok) {
const data = await res.json();
setAvailableSlots(data.slots || []);
}
} catch (e) {
console.error('Ошибка при получении доступных слотов', e);
} finally {
setLoadingSlots(false);
}
};
const handleSend = async (): Promise<boolean> => {
try {
// Сначала сохраняем в БД
if (formData.date && formData.time && formData.room) {
const bookingRes = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
date: formData.date,
time: formData.time,
}),
});
if (!bookingRes.ok) {
const error = await bookingRes.json();
alert(error.error || 'Ошибка при создании бронирования');
return false;
}
}
// Затем отправляем в Telegram
const res = await fetch('/api/send-to-telegram', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
time: formData.time || '—',
}),
});
return res.ok;
} catch (e) {
console.error('Ошибка сети', e);
return false;
}
};
useEffect(() => {
if (isOpen) {
setIsVisible(true);
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
setShouldAnimateIn(true);
});
} else {
setChoice(null);
setChoiceRoom(null);
setTransRoom(null);
setSelectedDate(today);
setSelectedTime(null);
setAvailableSlots([]);
document.body.style.overflow = '';
setShouldAnimateIn(false);
const timeout = setTimeout(() => setIsVisible(false), 300);
return () => clearTimeout(timeout);
}
}, [isOpen]);
// Загружаем доступные слоты при изменении даты или комнаты
useEffect(() => {
if (formData.date && formData.room && (screen === 'pc3' || screen === 'pc3-2')) {
fetchAvailableSlots(formData.date, formData.room);
}
}, [formData.date, formData.room, screen]);
if (!isOpen && !isVisible) return null;
return (
<div>
{(isOpen || isVisible) && (
<Overlay>
<ModalWrapper $animateIn={shouldAnimateIn}>
<CloseButton onClick={close}>×</CloseButton>
{screen === 'start' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<p>2</p>
<p>3</p>
<p>4</p>
</StepsModal>
<SectionTitle>Выбери чем ты хочешь заняться в клубе</SectionTitle>
<TwoColumns>
{options.map((option, index) => (
<ChoiceBtn
className={'big_btn'}
key={index}
$selected={choice===option.value}
onClick={()=> {
setChoice(option.value)
setFormData({type: option.value})
}}
>
<span>{option.label}</span>
</ChoiceBtn>
))}
</TwoColumns>
<StyledButton
disabled={!choice}
onClick={()=>{
if(choice==='pc') goTo('pc2');
else if(choice==='ps') goTo('playstation');
else if(choice==='tv') goTo('televis');
else if(choice==='zayvka') goTo('zayvka');
}}
label={"Далее"} padding={"8px 46px"} fontSize={"32px"} />
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
</>
)}
{screen === 'pc2' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<p>3</p>
<p>4</p>
</StepsModal>
<SectionTitle className={"marginTitle"} style={{marginTop:"140px"}}>в каком зале планируешь играть?</SectionTitle>
<TwoColumns>
{rooms.map((room, index) => (
<ChoiceBtn
key={index}
$selected={choiceRoom===room.title}
onClick={()=>setChoiceRoom(room.title)}
>
<h3>{room.title}</h3>
<span>{room.label}</span>
</ChoiceBtn>
))}
</TwoColumns>
<StyledButton
disabled={!choiceRoom}
onClick={() => {
goTo("pc3");
setFormData({ room: choiceRoom ?? undefined });
}}
label={"Далее"} padding={"8px 46px"} fontSize={"32px"} />
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('start')}}>Назад</BackBtn>
</>
)}
{screen === 'pc3' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<p>4</p>
</StepsModal>
<SectionTitle>выбери дату и время</SectionTitle>
<Input
type="date"
value={selectedDate}
min={today}
style={{ width: "260px", marginBottom: "30px" }}
onChange={(e) => {
const value = e.target.value;
setSelectedDate(value);
setSelectedTime(null);
setFormData({ date: value, time: undefined });
}}
/>
{formData.date && (
<>
<InputLabel style={{ marginBottom: "20px" }}>
{loadingSlots ? 'Загрузка доступных слотов...' : 'Выбери время'}
</InputLabel>
<TimeSlotGrid>
{availableSlots.map((slot) => (
<TimeSlotBtn
key={slot.time}
$selected={selectedTime === slot.time}
$available={slot.available}
disabled={!slot.available}
onClick={() => {
if (slot.available) {
setSelectedTime(slot.time);
setFormData({ time: slot.time });
}
}}
>
{slot.time}
</TimeSlotBtn>
))}
</TimeSlotGrid>
{availableSlots.length > 0 && (
<InputLabel style={{ fontSize: "14px", marginTop: "10px" }}>
Занято: {availableSlots.filter(s => !s.available).length} / {availableSlots.length} слотов
</InputLabel>
)}
</>
)}
<StyledButton
disabled={!selectedDate || !selectedTime}
onClick={() => {
goTo("pc4")
setFormData({ date: selectedDate, time: selectedTime || undefined });
}}
label="Далее"
padding="8px 46px"
fontSize="32px"
/>
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('pc2')}}>Назад</BackBtn>
</>
)}
{screen === 'pc3-2' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<p>4</p>
</StepsModal>
<SectionTitle>выбери дату и время</SectionTitle>
<Input
type="date"
value={selectedDate}
min={today}
style={{ width: "260px", marginBottom: "30px" }}
onChange={(e) => {
const value = e.target.value;
setSelectedDate(value);
setSelectedTime(null);
setFormData({ date: value, time: undefined });
}}
/>
{formData.date && formData.room && (
<>
<InputLabel style={{ marginBottom: "20px" }}>
{loadingSlots ? 'Загрузка доступных слотов...' : 'Выбери время'}
</InputLabel>
<TimeSlotGrid>
{availableSlots.map((slot) => (
<TimeSlotBtn
key={slot.time}
$selected={selectedTime === slot.time}
$available={slot.available}
disabled={!slot.available}
onClick={() => {
if (slot.available) {
setSelectedTime(slot.time);
setFormData({ time: slot.time });
}
}}
>
{slot.time}
</TimeSlotBtn>
))}
</TimeSlotGrid>
{availableSlots.length > 0 && (
<InputLabel style={{ fontSize: "14px", marginTop: "10px" }}>
Занято: {availableSlots.filter(s => !s.available).length} / {availableSlots.length} слотов
</InputLabel>
)}
</>
)}
<StyledButton
disabled={!selectedDate || !selectedTime}
onClick={() => {
goTo("trans")
setFormData({ date: selectedDate, time: selectedTime || undefined });
}}
label="Далее"
padding="8px 46px"
fontSize="32px"
/>
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('playstation')}}>Назад</BackBtn>
</>
)}
{screen === 'pc4' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
</StepsModal>
<InputLabel className={"marginTitle"} style={{ marginTop: '100px' }}>Свяжемся с тобой для подтверждения брони и уточнения времени</InputLabel>
<Input
placeholder={"Введи свое имя"}
onChange={(e) => {
setFormData({ name: e.target.value })
}}
/>
<InputLabel>Во что планируешь играть?</InputLabel>
<Input
placeholder={"Введи игру"}
onChange={(e) => {
setFormData({ game: e.target.value })
}}
/>
<InputLabel>Введите номер телефона</InputLabel>
<MaskedInput
mask="+0 (000) 000-00-00"
placeholder="+_ (___) ___-__-__"
value={formData.contact || ''}
onAccept={(value) => setFormData({ contact: value })}
unmask={false}
/>
<InputLabel>Выбери удобный способ для связи</InputLabel>
<div className={'contacts'} style={{ display: 'flex', gap: '20px', margin: '0px 0 40px' }}>
{contactOptions.map((opt) => (
<button
key={opt.type}
onClick={() => setFormData({ contactMethod: opt.type})}
style={{
borderRadius: '12px',
padding: '10px',
background: formData.contactMethod === opt.type ? gradient : 'transparent',
cursor: 'pointer'
}}
>
<Image src={opt.icon} alt={opt.type} width={48} height={48} loading={'lazy'} />
</button>
))}
</div>
<StyledButton
disabled={!formData.name || !formData.contact || !formData.contactMethod}
onClick={async () => {
const success = await handleSend();
if(success) {
goTo("ready");
}
}}
label="Далее"
padding="8px 46px"
fontSize="32px"
/>
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('pc3')}}>Назад</BackBtn>
</>
)}
{screen === 'trans' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
</StepsModal>
<InputLabel style={{ marginTop: '100px' }}>Свяжемся с тобой для подтверждения брони и уточнения времени</InputLabel>
<Input
placeholder={"Введи свое имя"}
onChange={(e) => {
setFormData({ name: e.target.value })
}}
/>
<InputLabel>Введите номер телефона</InputLabel>
<MaskedInput
mask="+0 (000) 000-00-00"
placeholder="+_ (___) ___-__-__"
value={formData.contact || ''}
onAccept={(value) => setFormData({ contact: value })}
unmask={false}
/>
<InputLabel>Выбери удобный способ для связи</InputLabel>
<div style={{ display: 'flex', gap: '20px', margin: '0px 0 40px' }}>
{contactOptions.map((opt) => (
<button
key={opt.type}
onClick={() => setFormData({ contactMethod: opt.type })}
style={{
borderRadius: '12px',
padding: '10px',
background: formData.contactMethod === opt.type ? gradient : 'transparent',
cursor: 'pointer'
}}
>
<Image src={opt.icon} alt={opt.type} width={48} height={48} loading={'lazy'} />
</button>
))}
</div>
<StyledButton
disabled={!formData.name || !formData.contact || !formData.contactMethod}
onClick={async () => {
const success = await handleSend();
if (success) {
goTo("ready");
}
}}
label="Далее"
padding="8px 46px"
fontSize="32px"
/>
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('pc3-2')}}>Назад</BackBtn>
</>
)}
{screen === 'playstation' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<p>3</p>
<p>4</p>
</StepsModal>
<SectionTitle className={"marginTitle"} style={{marginTop:"140px"}}>в каком зале планируешь играть?</SectionTitle>
{transRooms.map((room, index) => (
<ChoiceBtn
key={index}
$selected={transRoom===room.title}
onClick={()=>setTransRoom(room.title)}
style={{marginBottom:"18px", maxWidth:"510px", width:"80%"}}
>
<h3>{room.title}</h3>
<span>{room.label}</span>
</ChoiceBtn>
))}
<StyledButton
disabled={!transRoom}
onClick={() => {
goTo("pc3");
setFormData({ room: transRoom ?? undefined});
}}
label={"Далее"} padding={"8px 46px"} fontSize={"32px"} />
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('start')}}>Назад</BackBtn>
</>
)}
{screen === 'televis' && (
<>
<TitleModal>забронируй место в <span>strike arena</span></TitleModal>
<StepsModal>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<span> <Image src={"/icons/tick.svg"} alt={'tick'} width={40.5} height={29.5} loading={"lazy"} /> </span>
<p>3</p>
<p>4</p>
</StepsModal>
<SectionTitle className={"marginTitle"} style={{marginTop:"140px"}}>в каком зале планируешь играть?</SectionTitle>
{transRooms.map((room, index) => (
<ChoiceBtn
key={index}
$selected={transRoom===room.title}
onClick={()=>setTransRoom(room.title)}
style={{marginBottom:"18px", maxWidth:"510px", width:"80%"}}
>
<h3>{room.title}</h3>
<span>{room.label}</span>
</ChoiceBtn>
))}
<StyledButton
disabled={!transRoom}
onClick={() => {
goTo("pc3-2");
setFormData({ room: transRoom ?? undefined});
}}
label={"Далее"} padding={"8px 46px"} fontSize={"32px"} />
<BronBtn onClick={()=>{goTo('zayvka')}}>Забронировать</BronBtn>
<BackBtn onClick={()=>{goTo('start')}}>Назад</BackBtn>
</>
)}
{screen === 'zayvka' && (
<>
<TitleModal style={{position:"relative", top:"20px"}}>забронируй место в <span>strike arena</span></TitleModal>
<InputLabel style={{marginBottom:"50px"}}>Оставь заявку и мы тебе перезвоним поможем забронировать место</InputLabel>
<Input
placeholder={"Введи свое имя"}
onChange={(e) => {
setFormData({ name: e.target.value })
}}
/>
<MaskedInput
mask="+0 (000) 000-00-00"
placeholder="+_ (___) ___-__-__"
value={formData.contact || ''}
onAccept={(value) => setFormData({ contact: value })}
unmask={false}
style={{marginBottom:"50px"}}
/>
<StyledButton
disabled={!formData.name || !formData.contact}
onClick={async () => {
const success = await handleSend();
if (success) {
goTo("ready");
}
}}
label="Оставить заявку"
padding="8px 46px"
fontSize="24px"
/>
<BackBtn onClick={()=>{goTo('start')}}>Назад</BackBtn>
</>
)}
{screen === 'zayvka-game' && (
<>
<TitleModal style={{position:"relative", top:"20px"}}>забронируй место в <span>strike arena</span></TitleModal>
<InputLabel style={{marginBottom:"50px"}}>Оставь заявку и мы тебе перезвоним поможем забронировать место</InputLabel>
<Input
placeholder={"Введи свое имя"}
onChange={(e) => {
setFormData({ name: e.target.value })
}}
/>
<MaskedInput
mask="+0 (000) 000-00-00"
placeholder="+_ (___) ___-__-__"
value={formData.contact || ''}
onAccept={(value) => setFormData({ contact: value })}
unmask={false}
style={{marginBottom:"50px"}}
/>
<StyledButton
disabled={!formData.name || !formData.contact}
onClick={async () => {
const success = await handleSend();
if (success) {
goTo("ready");
}
}}
label="Оставить заявку"
padding="8px 46px"
fontSize="24px"
/>
</>
)}
{screen === 'ready' && (
<>
<Image style={{marginBottom:"30px",}} src={"/icons/tick.svg"} alt={"tick"} width={160} height={122} loading={"lazy"} />
<TitleModal className={'readyTitle'} style={{position:"relative", top:0}}><span>{formData.name}, спасибо,</span><br/> Мы приняли твою заявку<br/> на бронь</TitleModal>
<InputLabel className={'readyText'} style={{fontSize:"24px",marginBottom:"50px"}}>Aдмин клуба STRIKE ARENA свяжется с тобой в ближайшее время для уточнения времени и подтверждения твоей заявки</InputLabel>
<StyledButton
onClick={close}
label="Назад"
padding="8px 46px"
fontSize="32px"
/>
</>
)}
</ModalWrapper>
</Overlay>
)}
</div>
);
}
+322
View File
@@ -0,0 +1,322 @@
import React, {useEffect, useState} from 'react';
import styled from "styled-components";
import Image from "next/image";
import {blackColor, gradient, lightGreenColor} from "@/styles/colors";
const features = [
{
icon: 'stairs.svg',
text: '2 игровых этажа',
image: '1.png'
},
{
icon: 'vip.svg',
text: 'Все VIP кабины',
image: '2.png'
},
{
icon: 'energy.svg',
text: 'Мощное железо',
image: '3.png'
},
{
icon: 'wc.svg',
text: 'WC для девочек',
image: '4.png'
},
{
icon: 'smoke.png',
text: 'Дымный Lounge',
image: '5.png'
},
{
icon: 'price.svg',
text: 'Выгодные пакеты',
image: '6.png'
},
{
icon: 'group.svg',
text: '3 VIP Bootcamp',
image: '7.png'
},
{
icon: 'vent.svg',
text: 'Хорошая вентиляция',
image: '8.png'
},
{
icon: 'room.svg',
text: 'Звукоизоляция',
image: '9.png'
},
{
icon: 'guard.svg',
text: 'Безопасность',
image: '10.png'
},
{
icon: 'food.svg',
text: 'Ассортимент еды',
image: '11.png'
},
{
icon: 'game.svg',
text: '5 Зон с PS 5 Pro',
image: '12.png'
},
]
const FeaturesContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
position: relative;
&::after{
position: absolute;
content: "";
width: 400px;
height: 600px;
top: 80px;
left: -500px;
transform: rotate(40deg);
border-radius: 50%;
filter: blur(100px);
opacity: .5;
background: ${lightGreenColor};
}
@media (max-width: 1260px) {
justify-content: center;
gap: 40px;
}
@media (max-width: 1100px) {
gap: 30px;
}
@media (max-width: 900px) {
flex-direction: column;
margin-top: 30px;
gap: 30px;
}
`
const TitleBlock = styled.h2`
line-height: 1;
width: 100%;
@media (max-width: 1400px) {
width: 100% !important;
margin: 0 auto;
font-size: 40px !important;
}
@media (max-width: 1260px) {
width: 90% !important;
margin: 0 auto;
}
@media (max-width: 1100px) {
font-size: 40px !important;
text-align: center !important;
margin-bottom: 32px !important;
}
@media (max-width: 470px) {
font-size: 32px !important;
}
`
const FeaturesGrid = styled.div`
display: grid;
grid-template-columns: repeat(4, 140px);
gap: 12px 16px;
@media (max-width: 1100px) {
grid-template-columns: repeat(4, 120px);
gap: 10px 12px;
}
@media (max-width: 900px) {
grid-template-columns: repeat(3, 130px);
gap: 12px;
}
@media (max-width: 480px) {
grid-template-columns: repeat(3, 100px);
gap: 10px;
}
@media (max-width: 350px) {
grid-template-columns: repeat(3, 90px);
gap: 8px;
}
`
const FeaturesImage = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
const ImageWrapper = styled.div`
position: relative;
width: 439px;
height: 618px;
@media (max-width: 1100px) {
width: 380px;
height: 534px;
}
@media (max-width: 900px) {
width: 380px;
height: 534px;
}
@media (max-width: 480px) {
width: 320px;
height: 350px;
}
@media (max-width: 350px) {
width: 280px;
height: 304px;
}
img {
position: absolute;
top: 0;
left: 0;
transition: opacity 0.5s ease;
opacity: 0;
width: 100%;
height: 100%;
object-fit: cover;
&.visible {
opacity: 1;
}
}
`;
const FeaturesBlock = styled.div<{ $active?: boolean }>`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 4px;
text-align: center;
background: ${blackColor};
border-radius: 20px;
padding: 18px 0;
position: relative;
.highlight {
position: absolute;
width: 104%;
height: 104%;
top: -2%;
left: -2%;
background: ${gradient};
border-radius: 20px;
z-index: -1;
transition: transform .3s ease;
transform: ${({ $active }) => ($active ? 'scale(1)' : 'scale(.9)')};
}
img{
height: 70px;
}
p{
cursor: default;
width: 120px;
font-size: 19px;
line-height: 1.2;
}
@media (max-width: 1100px) {
padding: 12px 0;
img {
height: 45px;
}
p {
font-size: 16px;
width: 100px;
}
}
@media (max-width: 900px) {
padding: 14px 0;
img {
height: 55px;
}
p {
font-size: 16px;
width: 100px;
margin-top: 6px;
}
}
@media (max-width: 480px) {
padding: 12px 0;
img {
height: 45px;
}
p {
font-size: 14px;
width: 90px;
margin-top: 4px;
}
}
@media (max-width: 350px) {
padding: 10px 0;
img {
height: 40px;
}
p {
font-size: 13px;
width: 80px;
}
}
`
const FeaturesList = () => {
const [activeIndex, setActiveIndex] = useState<number>(0)
const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= 1024)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
return isMobile
}
const isMobile = useIsMobile()
return (
<div>
<TitleBlock style={{ textAlign: 'start' }}>
<span>Твое топовое</span><br />
игровое пространство
</TitleBlock>
<FeaturesContent>
<FeaturesGrid>
{features.map((feature, index) => (
<FeaturesBlock
key={index}
$active={activeIndex === index}
onMouseEnter={!isMobile ? () => setActiveIndex(index) : undefined}
onClick={isMobile ? () => setActiveIndex(index) : undefined}
>
<div className="highlight" />
<Image src={'/icons/' + feature.icon} alt={'startLeft'} width={40} height={40} loading={'lazy'} />
<p>{feature.text}</p>
</FeaturesBlock>
))}
</FeaturesGrid>
<FeaturesImage>
<ImageWrapper>
{features.map((f, i) => (
<Image
key={i}
src={`/images/features/${f.image}`}
alt={'feature'}
width={439}
height={618}
className={activeIndex === i ? 'visible' : ''}
style={{ zIndex: activeIndex === i ? 1 : 0 }}
/>
))}
</ImageWrapper>
</FeaturesImage>
</FeaturesContent>
</div>
);
};
export default FeaturesList;
+294
View File
@@ -0,0 +1,294 @@
'use client'
import React, {useEffect, useState} from 'react';
import styled from "styled-components";
import {PriceHeader_btn} from "@/app/main.styled";
import Image from "next/image";
import Link from "next/link";
import {lightGreenColor} from "@/styles/colors";
import {useBronStore} from "@/store/useBronStore";
const GameHeader = styled.div`
display: flex;
justify-content: center;
gap: 170px;
align-items: center;
button{
margin-left: 0;
}
@media (max-width: 800px) {
gap: 40px;
flex-direction: row;
button {
font-size: 42px;
width: auto;
padding: 0 20px;
&::after {
width: 100%;
}
}
}
@media (max-width: 500px) {
gap: 20px;
button {
font-size: 36px;
padding: 0 15px;
}
}
@media (max-width: 400px) {
button {
font-size: 32px;
}
}
`
const GameContent = styled.div`
display: grid;
margin-top: 100px;
grid-template-columns: repeat(4, 270px);
justify-content: center;
align-items: center;
gap: 45px;
position: relative;
-webkit-transform: translateZ(0);
will-change: transform;
&::after{
content: "";
position: absolute;
bottom: 0;
right: -30%;
background: ${lightGreenColor};
width: 200px;
height: 400px;
filter: blur(120px);
opacity: .6;
z-index: -1;
}
@media (max-width: 1300px) {
grid-template-columns: repeat(3, 270px);
}
@media (max-width: 1000px) {
grid-template-columns: repeat(3, 200px);
}
@media (max-width: 800px) {
grid-template-columns: repeat(2, 240px);
gap: 30px;
margin-top: 60px;
}
@media (max-width: 550px) {
grid-template-columns: repeat(2, 180px);
gap: 20px;
}
@media (max-width: 400px) {
grid-template-columns: repeat(2, 150px);
gap: 15px;
}
`
const GameBlock = styled.div`
cursor: pointer;
p{
font-size: 30px;
margin-bottom: 6px;
line-height: 1;
text-transform: none;
}
img {
width: 300px;
height: 155px;
border-radius: 15px;
object-fit: cover;
display: block;
}
@media (max-width: 800px) {
img {
width: 100%;
height: 130px;
}
p {
font-size: 22px;
}
}
@media (max-width: 550px) {
img {
height: 100px;
}
p {
font-size: 18px;
}
}
@media (max-width: 400px) {
img {
height: 85px;
}
p {
font-size: 16px;
}
}
`
const GameText = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
margin-top: 80px;
div{
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
}
h3{
line-height: 1;
font-size: 36px;
text-align: center;
text-transform: none;
span{
background: #8921ff;
text-transform: uppercase;
padding: 4px 18px;
}
}
@media (max-width: 800px) {
flex-direction: column;
gap: 25px;
margin-top: 60px;
h3 {
font-size: 32px;
max-width: 80%;
margin: 0 auto;
}
div {
gap: 25px;
}
}
@media (max-width: 550px) {
gap: 20px;
margin-top: 50px;
h3 {
font-size: 28px;
max-width: 90%;
}
div {
gap: 20px;
img {
width: 65px;
height: 65px;
}
}
}
@media (max-width: 400px) {
gap: 15px;
margin-top: 40px;
h3 {
font-size: 24px;
}
div {
gap: 15px;
img {
width: 55px;
height: 55px;
}
}
}
`
interface Game{
id: number;
title: string;
image_url: string;
platform: string;
}
type Links = {
tg?: string;
whatsapp?: string;
}
const GameList:React.FC<Links> = ({tg, whatsapp}) => {
const [platform, setPlatform] = useState<'pc'|'ps'>('pc')
const [games, setGames] = useState<Game[]>([]);
const open = useBronStore((s) => s.open);
useEffect(()=>{
fetch(`/api/games?platform=${platform}`)
.then(res=>res.json())
.then(setGames)
}, [platform])
const [showAll, setShowAll] = useState(false)
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 760)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
return (
<div>
<GameHeader>
<PriceHeader_btn onClick={()=> setPlatform('pc')} className={platform==='pc' ? 'active' : ''}>
Пк игры:
</PriceHeader_btn>
<PriceHeader_btn onClick={()=> setPlatform('ps')} className={platform==='ps' ? 'active' : ''}>
Ps игры:
</PriceHeader_btn>
</GameHeader>
<GameContent>
{(isMobile && !showAll ? games.slice(0, 8) : games).map((game) => (
<GameBlock key={game.id} onClick={() => open('zayvka-game')}>
<p>{game.title}</p>
<Image
src={`/api/uploads/games/${game.image_url}`}
alt={game.title}
width={270}
height={140}
loading="lazy"
/>
</GameBlock>
))}
</GameContent>
{isMobile && games.length >8 && (
<div style={{ textAlign: 'center', marginTop: '42px', width:"100%"}}>
<button
onClick={() => setShowAll(prev => !prev)}
style={{
fontSize: '20px',
padding: '12px 24px',
borderRadius: '12px',
border: 'none',
background: lightGreenColor,
color: '#000',
cursor: 'pointer'
}}
>
{showAll ? 'Скрыть' : 'Показать все'}
</button>
</div>
)}
<GameText>
<h3><span>Не нашел нужную игру?</span><br/>Напиши нам и мы ее скачаем!</h3>
<div>
{tg ? (
<Link href={tg} target={"_blank"}>
<Image src={'/icons/tg_primary.png'} alt={'tg'} width={78} height={78} loading={"lazy"} />
</Link>
) : (
<Link href={'/'}>
<Image src={'/icons/tg_primary.png'} alt={'tg'} width={78} height={78} loading={"lazy"} />
</Link>
)}
{whatsapp ? (
<Link href={whatsapp} target={"_blank"}>
<Image src={'/icons/wa_primary.png'} alt={'whatsapp'} width={81} height={81} loading={"lazy"} />
</Link>
) : (
<Link href={'/'}>
<Image src={'/icons/wa_primary.png'} alt={'whatsapp'} width={81} height={81} loading={"lazy"} />
</Link>
)}
</div>
</GameText>
</div>
);
};
export default GameList;
+268
View File
@@ -0,0 +1,268 @@
import React, {useState} from 'react';
import Link from "next/link";
import Image from "next/image";
import {useBronStore} from "@/store/useBronStore";
import styled from "styled-components";
type HeaderPageProps = {
whatsapp?: string;
tg?: string;
inst?: string;
phone?: string;
gis2?: string;
}
const Header = styled.header`
margin: 0 auto;
max-width: 1480px;
height: 148px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
&::after{
content: "";
width: 100vw;
height:100%;
border-radius: 0 0 20px 20px;
background-color: rgba(14,14,23,.7);
backdrop-filter: blur(100%);
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: -1;
}
@media (max-width: 1500px) {
height: 120px;
padding: 0 40px;
}
@media (max-width: 480px) {
height: 100px;
}
`
const HeaderSchedule = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
h3{
font-size: 48px;
}
p{
font-size: 20px;
line-height: 1;
}
@media (max-width: 600px) {
display: none;
}
`
const HeaderPhone = styled.div`
display: flex;
align-items: start;
flex-direction: column;
justify-content: start;
margin-left: 33px;
h4{
font-size: 18px;
margin-top: 5px;
cursor: pointer;
font-weight: normal;
}
p{
font-size: 20px;
line-height: 1;
}
`
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;
}
&.desktopOnly {
@media (max-width: 900px) {
display: none;
}
}
.footerPhone{
h4{
text-align: center;
font-size: 20px;
}
}
@media (max-width: 1100px) {
.headerImage img{
height: 40px;
}
.gis2Link{
margin-right: 2px;
}
.tgLink{
margin: 0 6px 0 -2px;
}
}
@media (max-width: 900px) {
width: 100%;
justify-content:space-between;
}
@media (max-width: 1100px) {
.headerImage img{
height: 40px;
}
.gis2Link{
margin-right: 2px;
}
.tgLink{
margin: 0 6px 0 -2px;
}
}
@media (max-width: 480px) {
.logo img{
width: 150px;
}
}
`
const MenuButton = styled.button`
display: none;
background: none;
border: none;
cursor: pointer;
@media (max-width: 900px) {
display: flex;
align-items: center;
justify-content: center;
}
img {
width: 40px;
height: 40px;
transition: transform 0.3s ease;
}
&:hover img {
transform: scale(1.1);
}
`
const MobileMenu = styled.div<{ $isOpenMenu: boolean }>`
position: fixed;
top: 0;
right: ${({ $isOpenMenu }) => ($isOpenMenu ? '0' : '-100%')};
width: 100%;
height: 100vh;
background: rgba(14, 14, 23, 0.95);
backdrop-filter: blur(10px);
z-index: 999;
transition: right 0.3s ease-in-out;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
a {
color: white;
font-size: 28px;
text-decoration: none;
}
button {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
}
@media (min-width: 901px) {
display: none;
}
`;
const HeaderPage: React.FC<HeaderPageProps> = ({whatsapp, tg, inst, gis2, phone}) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const open = useBronStore((s) => s.open);
return (
<Header>
<HeaderBlock>
<Link href='/' className={'logo'}>
<Image src={'/icons/logo.png'} alt={'logo'} width={196} height={52} loading={'lazy'} />
</Link>
<HeaderSchedule>
<h3>24/7</h3>
<p>Пиши нам<br />мы на связи</p>
</HeaderSchedule>
<MenuButton onClick={() => setIsMenuOpen(true)}>
<Image src="/icons/menu.svg" alt="menu" width={40} height={40} loading="lazy" />
</MenuButton>
</HeaderBlock>
<HeaderBlock className={"desktopOnly"}>
{gis2 ? (
<Link href={gis2} 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>
)}
{whatsapp ? (
<Link href={whatsapp} className="headerImage" target="_blank">
<Image src="/icons/whatsapp.png" alt="whatsapp" width={58} height={58} loading="lazy" />
</Link>
) : (
<Link href={'/'} className="headerImage" target="_blank">
<Image src="/icons/whatsapp.png" alt="whatsapp" width={58} height={58} loading="lazy" />
</Link>
)}
{tg ? (
<Link href={tg} className="tgLink headerImage" target="_blank">
<Image src="/icons/tg.png" alt="tg" width={48} height={48} loading="lazy" />
</Link>
) : (
<Link href={'/'} className="tgLink headerImage" target="_blank">
<Image src="/icons/tg.png" alt="tg" width={48} height={48} loading="lazy" />
</Link>
)}
{inst ? (
<Link href={inst} 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" />
)}
<HeaderPhone>
<p style={{ marginBottom: 0 }}>{phone || '+7 775 260 85 59'}</p>
<h4 onClick={() => open('zayvka-game')}>Заказать звонок</h4>
</HeaderPhone>
</HeaderBlock>
<MobileMenu $isOpenMenu={isMenuOpen}>
<button onClick={() => setIsMenuOpen(false)} style={{fontSize: "32px"}}>
Close
</button>
{gis2 && <Link href={gis2} target="_blank">2 ГИС</Link>}
{whatsapp && <Link href={whatsapp} target="_blank">WhatsApp</Link>}
{tg && <Link href={tg} target="_blank">Telegram</Link>}
{phone && <Link href={`tel:${phone}`}>{phone}</Link>}
</MobileMenu>
</Header>
);
};
export default HeaderPage;
+570
View File
@@ -0,0 +1,570 @@
'use client'
import React, {useEffect, useState} from 'react';
import styled from "styled-components";
import {blackColor, darkGreenColor, lightGreenColor} from "@/styles/colors";
import {PriceHeader_btn} from "@/app/main.styled";
type PriceRow = {
day_type: string
hour_1: string
hour_3: string
hour_5: string
night: string
}
const PriceHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
h2{
font-size: 64px;
margin: 0;
width: max-content;
line-height: 1;
text-align: start;
}
@media (max-width: 1400px) {
width: 90%;
margin: 0 auto;
}
@media (max-width: 1080px) {
width: 80%;
h2 {
font-size: 40px;
}
div {
display: flex;
margin-top: 0;
justify-content: center;
align-items: center;
gap: 24px;
}
}
@media (max-width: 900px) {
h2{
font-size: 40px;
}
}
@media (max-width: 600px) {
h2 {
font-size: 42px;
}
div {
gap: 1px;
button {
font-size: 24px;
padding: 8px 16px;
}
}
}
@media (max-width: 420px) {
flex-direction: column;
gap: 24px;
h2 {
font-size: 36px;
}
div {
gap: 0px;
width: 100%;
flex-direction: row;
button {
font-size: 24px;
padding: 6px 12px;
}
}
}
`
const PriceControl = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin: 80px auto;
button{
margin-left: 0;
font-size: 40px;
width: 100%;
text-align: center;
&::after{
width: 100%;
left: 0;
transform: translateX(0);
}
}
@media (max-width: 1400px) {
width: 90%;
button{
font-size: 34px;
}
}
@media (max-width: 1080px) {
width: 80%;
margin: 60px auto;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
button {
font-size: 24px;
flex: 1;
width: max-content;
white-space: nowrap;
padding: 12px 0 0;
transition: all 0.3s ease;
&::after{
width: 130%;
}
}
}
@media (max-width: 800px) {
gap: 8px;
button {
font-size: 20px;
padding: 0;
&::after{
width: 110%;
}
}
}
@media (max-width: 600px) {
gap: 6px;
button {
font-size: 16px;
padding: 0;
min-width: 0;
}
}
@media (max-width: 500px) {
gap: 6px;
margin: 50px auto 40px;
button {
font-size: 16px;
padding: 6px 10px;
min-width: 100px;
margin-bottom: 20px;
}
}
@media (max-width: 400px) {
gap: 4px;
button {
font-size: 16px;
padding: 6px 10px;
min-width: 80px;
}
}
`
const PriceContent = styled.div`
position: relative;
font-size: 40px;
th{
width: 300px;
height:150px;
line-height: 1;
text-align: center;
background: transparent;
font-weight: normal;
}
td{
width: 300px;
height:150px;
background: ${blackColor};
text-align: center;
}
.tableBottom{
border-bottom-right-radius: 20px;
}
&::after{
content: "";
position: absolute;
top: 0;
left: 0;
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
width: 100%;
height: 100%;
z-index: -1;
border: none;
border-radius: 0 0 20px 0px;
}
.customTable {
all: unset;
transition: all 0.3s ease;
th, td {
min-width: 180px;
height: 150px;
padding: 16px;
text-align: center;
border: 1px solid ${darkGreenColor};
transition: all 0.3s ease;
}
td{
border: 1px solid ${darkGreenColor};
padding: 16px;
}
thead th:nth-child(1){
border: none;
}
thead th{
border-left: 1px solid ${darkGreenColor};
}
tbody th{
border-top: 1px solid ${darkGreenColor};
}
}
@media(max-width: 1400px) {
display: flex;
justify-content:center;
font-size: 34px;
width: 90%;
margin: 0 auto;
th{
width: 220px;
height:100px;
}
td{
width: 220px;
height:100px;
}
.customTable {
th, td {
height: 130px;
padding: 16px;
}
}
}
@media (max-width: 1080px) {
font-size: 28px;
width: 80%;
.customTable {
th, td {
min-width: 160px;
height: 110px;
padding: 12px;
}
}
}
@media (max-width: 1000px) {
font-size: 24px;
.customTable{
th, td {
min-width: 100px;
height: 90px;
padding: 8px;
}
}
}
@media (max-width: 800px) {
font-size: 20px;
.customTable{
th, td {
min-width: 80px;
height: 90px;
padding: 8px;
}
}
}
@media (max-width: 600px) {
font-size: 16px;
.customTable{
th, td {
height: 70px;
padding: 6px;
}
}
}
@media (max-width: 400px) {
font-size: 16px;
.customTable{
th, td {
min-width: 80px;
height: 60px;
padding: 2px;
}
}
}
`
const TableWrapper = styled.div`
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 auto;
width: 100%;
transition: all 0.3s ease;
table {
min-width: 600px;
}
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: ${darkGreenColor};
border-radius: 4px;
}
@media (max-width: 1080px) {
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 40px;
background: linear-gradient(to right, transparent, rgba(0,0,0,0.2));
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover::after {
opacity: 1;
}
}
@media (max-width: 600px) {
&::-webkit-scrollbar {
height: 6px;
}
}
@media (max-width: 400px) {
&::-webkit-scrollbar {
height: 4px;
}
}
`
const PriceList = () => {
const [gameType, setGameType] = useState('ПК')
const [roomType, setRoomType] = useState('solo')
const [prices, setPrices] = useState<PriceRow[]>([])
const [isMobileView, setIsMobileView] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobileView(window.innerWidth < 500);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
async function fetchPrices() {
const res = await fetch(`/api/prices?gameType=${gameType}&roomType=${roomType}`);
const data = await res.json();
setPrices(data);
}
fetchPrices();
}, [gameType, roomType]);
return (
<div>
<PriceHeader>
<h2>Прайс</h2>
<div>
<PriceHeader_btn
onClick={() => {
setGameType('ПК')
setRoomType('solo')
}}
className={gameType === 'ПК' ? 'active' : ''}
>
Пк игры:
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => {
setGameType('PS')
setRoomType('lounge')
}}
className={gameType === 'PS' ? 'active' : ''}
>
PS игры:
</PriceHeader_btn>
</div>
</PriceHeader>
<PriceControl>
{gameType === 'ПК' ? (
<>
<PriceHeader_btn
onClick={() => setRoomType('solo')}
className={roomType === 'solo' ? 'active' : ''}
>
room solo
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => setRoomType('duo')}
className={roomType === 'duo' ? 'active' : ''}
>
room duo
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => setRoomType('trio')}
className={roomType === 'trio' ? 'active' : ''}
>
room trio
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => setRoomType('five')}
className={roomType === 'five' ? 'active' : ''}
>
room five
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => setRoomType('six')}
className={roomType === 'six' ? 'active' : ''}
>
room six
</PriceHeader_btn>
</>
) : (
<>
<PriceHeader_btn
onClick={() => setRoomType('lounge')}
className={roomType === 'lounge' ? 'active' : ''}
>
playstation<br/>lounge
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => setRoomType('vip_normal')}
className={roomType === 'vip_normal' ? 'active' : ''}
>
playstation<br/>vip средний
</PriceHeader_btn>
<PriceHeader_btn
onClick={() => setRoomType('vip_big')}
className={roomType === 'vip_big' ? 'active' : ''}
>
playstation<br/>vip большой
</PriceHeader_btn>
</>
)}
</PriceControl>
<PriceContent>
<TableWrapper>
{isMobileView ?
(<table className="customTable">
<thead>
{gameType === 'ПК' && (
<tr>
<th></th>
{prices.map((row, idx) => (
<th key={idx}>{row.day_type}</th>
))}
</tr>
)}
</thead>
<tbody>
{gameType === 'ПК' ? (
<>
<tr>
<th>1 ЧАС</th>
{prices.map((row, idx) => (
<td key={idx}>{row.hour_1}</td>
))}
</tr>
<tr>
<th>3 ЧАСА</th>
{prices.map((row, idx) => (
<td key={idx}>{row.hour_3}</td>
))}
</tr>
<tr>
<th>5 ЧАСОВ</th>
{prices.map((row, idx) => (
<td key={idx}>{row.hour_5}</td>
))}
</tr>
<tr>
<th>НОЧЬ</th>
{prices.map((row, idx) => (
<td key={idx}>{row.night}</td>
))}
</tr>
</>
) : (
<>
<tr>
<th>1 ЧАС</th>
{prices.map((row, idx) => (
<td key={idx}>{row.day_type}</td>
))}
</tr>
<tr>
<th>3 ЧАСА</th>
{prices.map((row, idx) => (
<td key={idx}>{row.hour_1}</td>
))}
</tr>
<tr>
<th>3 ЧАСА + КАЛЬЯН</th>
{prices.map((row, idx) => (
<td key={idx}>{row.hour_3}</td>
))}
</tr>
<tr>
<th>5 ЧАСОВ</th>
{prices.map((row, idx) => (
<td key={idx}>{row.hour_5}</td>
))}
</tr>
<tr>
<th>5 ЧАСОВ + КАЛЬЯН</th>
{prices.map((row, idx) => (
<td key={idx} className={idx === prices.length - 1 ? 'tableBottom' : ''}>{row.night}</td>
))}
</tr>
</>
)}
</tbody>
</table>)
:
(<table className="customTable">
<thead>
{gameType === 'ПК' ? (
<tr>
<th></th>
<th>1 ЧАС</th>
<th>3 ЧАСА</th>
<th>5 ЧАСОВ</th>
<th>НОЧЬ</th>
</tr>
) : (
<tr>
<th>1 ЧАС</th>
<th>3 ЧАСА</th>
<th>3 ЧАСА + Кальян</th>
<th>5 ЧАСОВ</th>
<th>5 ЧАСОВ + Кальян</th>
</tr>
)}
</thead>
<tbody>
{prices.map((row, idx) => (
<tr key={idx}>
{gameType === 'ПК' ? (<th>{row.day_type}</th>) : (<td>{row.day_type}</td>)}
<td>{row.hour_1}</td>
<td>{row.hour_3}</td>
<td>{row.hour_5}</td>
<td className={idx === prices.length - 1 ? 'tableBottom' : ''}>{row.night}</td>
</tr>
))}
</tbody>
</table>)
}
</TableWrapper>
</PriceContent>
</div>
);
};
export default PriceList;
+494
View File
@@ -0,0 +1,494 @@
'use client'
import React, {useEffect, useState} from 'react';
import styled from "styled-components";
import {blackColor, darkGreenColor, lightGreenColor} from "@/styles/colors";
import {AnimatePresence, motion} from "framer-motion";
type Room = {
id: number;
name: string;
description: string;
cpu: string;
gpu: string;
ram: string;
monitor: string;
mouse: string;
keyboard: string;
headphones: string;
chair: string;
bracket: string;
};
const RoomButton = styled.button`
width: 472px;
min-height: 100px;
display: flex;
justify-content: flex-start;
align-items: center;
padding: 24px 40px 16px;
background-color: ${blackColor};
border-radius: 20px;
color: white;
position: relative;
text-align: left;
font-size: 24px;
line-height: 1;
transition: all 0.3s ease;
.room-text{
width: 100%;
}
p {
margin-bottom: 10px;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
text-transform: uppercase;
}
&.active {
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
color: white;
}
@media (max-width: 1400px) {
width: 100%;
padding: 20px 30px 14px;
p {
font-size: 20px;
}
}
@media (max-width: 800px) {
padding: 20px 20px 18px;
display: flex;
justify-content: center;
min-height: 70px;
p {
text-align: center;
font-size: 18px;
margin-bottom: 0;
}
}
@media (max-width: 480px) {
padding: 14px 20px 10px;
min-height: 70px;
p {
font-size: 16px;
}
}
`;
const RoomTitle = styled.div`
font-size: 32px;
font-weight: 800;
text-transform: uppercase;
line-height: 1;
@media (max-width: 1400px) {
font-size: 28px;
}
@media (max-width: 800px) {
font-size: 24px;
}
@media (max-width: 480px) {
font-size: 20px;
}
`;
const Wrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: start;
width: 100%;
max-width: 1400px;
margin: 74px auto 0;
padding: 0 20px;
gap: 32px;
.roomsList {
display: flex;
justify-content: start;
align-items: start;
flex-direction: column;
gap: 12px;
width: 50%;
.gradient-border {
width: 100%;
border: 2px solid transparent;
border-radius: 20px;
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
background-origin: border-box;
}
}
@media (max-width: 1200px) {
width: 90%;
gap: 32px;
.roomsList {
width: 60%;
}
}
@media (max-width: 800px) {
flex-direction: column;
gap: 40px;
margin-top: 30px;
.roomsList {
width: 100%;
}
}
@media (max-width: 480px) {
padding: 0 12px;
margin-top: 30px;
gap: 30px;
}
`;
const RoomInfo = styled.div`
background: ${blackColor};
padding: 32px 52px;
height: 100%;
border-radius: 20px;
h4 {
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-transform: uppercase;
font-size: 36px;
text-align: center;
margin-bottom: 24px;
}
@media (max-width: 1200px) {
padding: 28px 20px;
h4 {
font-size: 32px;
margin-bottom: 20px;
}
}
@media (max-width: 1050px) {
padding:20px 36px;
}
@media (max-width: 800px) {
padding: 26px 32px 42px;
h4 {
line-height: 1;
font-size: 40px;
margin-bottom: 16px;
}
}
@media (max-width: 480px) {
padding: 20px 12px 32px;
h4 {
font-size: 32px;
margin-bottom: 0px;
}
}
`;
const RoomInfo_block = styled.div`
width: 100%;
max-width: 600px;
margin: 24px auto 0;
h5 {
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 24px;
text-transform: uppercase;
margin-bottom: 16px;
}
@media (max-width: 1200px) {
margin: 20px auto 0;
h5 {
font-size: 22px;
margin-bottom: 14px;
}
}
@media (max-width: 800px) {
margin: 16px auto 0;
h5 {
font-size: 22px;
margin-bottom: 12px;
text-align: center;
}
}
@media (max-width: 480px) {
margin: 32px auto 0;
h5 {
font-size: 24px;
margin-bottom: 10px;
}
}
`;
const RoomBorder = styled.div`
border: 2px solid transparent;
border-radius: 20px;
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
background-origin: border-box;
width: 100%;
height: 100%;
overflow: hidden;
@media (max-width: 1200px) {
width: 550px;
}
@media (max-width: 1050px) {
width: 450px;
}
@media (max-width: 900px) {
width: 380px;
}
@media (max-width: 800px) {
width: 100%;
margin: 0 auto;
}
`;
const RoomDetail = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24px;
text-transform: uppercase;
&:last-child {
margin-bottom: 0;
}
.label {
color: rgba(255, 255, 255, 0.8);
}
.value {
text-align: right;
max-width: 50%;
word-wrap: break-word;
}
@media (max-width: 1200px) {
font-size: 20px;
}
@media (max-width: 800px) {
font-size: 22px;
.value {
max-width: 45%;
}
}
@media (max-width: 480px) {
flex-direction: column;
align-items: center;
text-align: center;
gap: 2px;
margin-bottom: 20px;
.label {
font-size: 20px;
line-height: 1;
}
.value {
max-width: 100%;
text-align: center;
font-size: 20px;
line-height: 1;
}
}
`;
const RoomDescription = styled.div`
font-size: 20px;
font-weight: 600;
margin-top: 10px;
line-height: 1.3;
text-transform: none;
@media (max-width: 1400px) {
font-size: 18px;
}
@media (max-width: 800px) {
font-size: 16px;
}
@media (max-width: 480px) {
font-size: 15px;
}
`;
const RoomList = () => {
const [rooms, setRooms] = useState<Room[]>([]);
const [selectedRoom, setSelectedRoom] = useState<Room | null>(null);
useEffect(() => {
fetch('/api/rooms')
.then((res) => res.json())
.then((data) => {
setRooms(data)
setSelectedRoom(data[0])
})
}, []);
return (
<Wrapper>
<div className={'roomsList'}>
{rooms.map((room)=>(
<div key={room.id} className={'gradient-border'}>
<RoomButton
className={room.id === selectedRoom?.id ? 'active' : ''}
onClick={() => setSelectedRoom(room)}
>
<div className="room-text">
<RoomTitle>{room.name}</RoomTitle>
<RoomDescription>{room.description}</RoomDescription>
</div>
</RoomButton>
</div>
))}
</div>
<AnimatePresence mode="wait">
<div style={{width: '100%'}}>
{selectedRoom && (
selectedRoom.name === 'ps' ? (
<motion.div
key={selectedRoom.id}
initial={{opacity: 0, y: 50}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -50}}
transition={{duration: 0.4, ease: "easeOut"}}
>
<RoomBorder>
<RoomInfo>
<motion.h4
initial={{opacity: 0, y: -50}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.4, ease: "easeOut", delay: 0.1}}
>
{selectedRoom.name}
</motion.h4>
<motion.div
initial={{opacity: 0, y: 50}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.4, ease: "easeOut", delay: 0.2}}
>
<RoomInfo_block>
<h5>Cистемный блок</h5>
<RoomDetail>
<span className="label">Приставки</span>
<span className="value">{selectedRoom.cpu}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Геймпады</span>
<span className="value">{selectedRoom.gpu}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Телевизор</span>
<span className="value">{selectedRoom.ram}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Удобынй большой диван и стол</span>
</RoomDetail>
</RoomInfo_block>
</motion.div>
</RoomInfo>
</RoomBorder>
</motion.div>
) : (
<motion.div
key={selectedRoom.id}
initial={{opacity: 0, y: 50}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -50}}
transition={{duration: 0.4, ease: "easeOut"}}
>
<RoomBorder>
<RoomInfo>
<motion.h4
initial={{opacity: 0, y: -50}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.4, ease: "easeOut", delay: 0.1}}
>
{selectedRoom.name}
</motion.h4>
<motion.div
initial={{opacity: 0, y: 50}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.4, ease: "easeOut", delay: 0.2}}
>
<RoomInfo_block>
<h5>Cистемный блок</h5>
<RoomDetail>
<span className="label">Процессор</span>
<span className="value">{selectedRoom.cpu}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Озу</span>
<span className="value">{selectedRoom.ram}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Видеокарта</span>
<span className="value">{selectedRoom.gpu}</span>
</RoomDetail>
</RoomInfo_block>
<RoomInfo_block>
<h5>Периферия</h5>
<RoomDetail>
<span className="label">Мышка</span>
<span className="value">{selectedRoom.mouse}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Клавиатура</span>
<span className="value">{selectedRoom.keyboard}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Наушники</span>
<span className="value">{selectedRoom.headphones}</span>
</RoomDetail>
</RoomInfo_block>
<RoomInfo_block>
<h5>Монитор+Кронштейн</h5>
<RoomDetail>
<span className="label">Монитор</span>
<span className="value">{selectedRoom.monitor}</span>
</RoomDetail>
<RoomDetail>
<span className="label">Кронштейн</span>
<span className="value">{selectedRoom.bracket}</span>
</RoomDetail>
</RoomInfo_block>
<RoomInfo_block>
<RoomDetail>
<h5>Кресло</h5>
<span className="value">{selectedRoom.chair}</span>
</RoomDetail>
</RoomInfo_block>
</motion.div>
</RoomInfo>
</RoomBorder>
</motion.div>
)
)}
</div>
</AnimatePresence>
</Wrapper>
);
};
export default RoomList;
+47
View File
@@ -0,0 +1,47 @@
// components/StartupLoader.tsx
'use client'
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect } from 'react';
export default function StartupLoader() {
const [showLoader, setShowLoader] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setShowLoader(false), 500);
return () => clearTimeout(timer);
}, []);
return (
<AnimatePresence>
{showLoader && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6 }}
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
backgroundColor: 'black',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<motion.h1
initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
exit={{ scale: 0.8 }}
transition={{ duration: 0.6 }}
style={{ fontSize: '32px' }}
>
Загрузка...
</motion.h1>
</motion.div>
)}
</AnimatePresence>
);
}
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import styled from "styled-components";
import {gradient, lightGreenColor, whiteColor} from "@/styles/colors";
interface ButtonProps {
label: string,
padding: string,
fontSize: string,
onClick?: () => void,
disabled?: boolean
}
interface StyledProps {
$padding: string,
$fontSize: string,
}
const StyleButton = styled.button<StyledProps>`
background: ${gradient};
padding: ${props => props.$padding};
font-size: ${props => props.$fontSize};
color: ${whiteColor};
text-transform: uppercase;
border-radius: 16px;
position: relative;
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
background: ${lightGreenColor};
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: all 0.4s ease;
border-radius: 10%;
filter: blur(20px);
z-index: -1;
will-change: transform, opacity;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
&:hover::before {
transform: translate(-50%, -50%) scale(1);
opacity: .6;
}
`
const StyledButton = ({label, padding, fontSize, onClick, disabled}: ButtonProps) => {
return (
<StyleButton disabled={disabled} $padding={padding} $fontSize={fontSize} onClick={onClick}>
{label}
</StyleButton>
);
};
export default StyledButton;
+129
View File
@@ -0,0 +1,129 @@
// components/StyledMotion.ts
'use client'
import styled from 'styled-components';
import { motion } from 'framer-motion';
import {darkGreenColor, lightGreenColor} from "@/styles/colors";
export const MotionStartBlock = styled(motion.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: -80px;
}
img:nth-child(4){
right: 40px;
}
h1{
font-size: 64px;
line-height: 1;
text-align: center;
max-width: 648px;
margin-bottom: 40px;
span{
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
@media (max-width: 1600px) {
img:nth-child(4){
right: 0;
width: 422px;
}
img:nth-child(1){
width: 452px;
}
}
@media (max-width: 1400px) {
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: 1180px) {
img:nth-child(4){
width: 300px;
}
img:nth-child(1){
width: 340px;
}
h1{
font-size: 50px;
max-width: 508px;
margin-bottom: 24px;
}
button{
font-size: 32px;
}
}
@media (max-width: 1040px) {
img:nth-child(4),
img:nth-child(1){
display: none;
}
}
@media (max-width: 600px) {
padding: 0 20px;
height: 80vh;
h1{
font-size: 40px;
max-width: 400px;
}
button{
font-size: 26px;
padding: 12px 32px;
}
}
@media (max-width: 418px) {
h1{
font-size: 30px;
max-width: 300px;
}
button{
font-size: 26px;
padding: 12px 32px;
}
}
`;
+9
View File
@@ -0,0 +1,9 @@
import mysql from 'mysql2/promise'
export const bd = mysql.createPool({
host: process.env.MYSQL_HOST,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
charset: 'utf8mb4',
})
+16
View File
@@ -0,0 +1,16 @@
import jwt from 'jsonwebtoken'
interface AdminPayload {
role: string
}
export function verifyAdminToken(token: string | undefined): boolean {
if (!token) return false
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as AdminPayload
return decoded.role === 'admin'
} catch {
return false
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
export async function middleware(req: NextRequest) {
const token = req.cookies.get("token")?.value;
const url = req.nextUrl.clone();
if (req.nextUrl.pathname.startsWith("/admin") && req.nextUrl.pathname !== "/admin/login") {
if (!token) {
url.pathname = "/admin/login";
return NextResponse.redirect(url);
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
if (payload.role !== "admin") throw new Error();
} catch {
url.pathname = "/admin/login";
return NextResponse.redirect(url);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin", "/admin/:path*"],
};
+51
View File
@@ -0,0 +1,51 @@
import { create } from 'zustand'
type ScreenType =
| 'start'
| 'pc2'
| 'pc3'
| 'pc3-2'
| 'pc4'
| 'trans'
| 'televis'
| 'zayvka'
| 'zayvka-game'
| 'playstation'
| 'ready'
interface FormData {
type?: string
room?: string
date?: string
time?: string
name?: string
game?: string
contact?: string
contactMethod?: 'telegram' | 'whatsapp' | 'phone'
}
interface BronStore {
isOpen: boolean
screen: ScreenType
formData: FormData
open: (screen: ScreenType) => void
close: () => void
goTo: (screen: ScreenType) => void
setFormData: (data: Partial<FormData>) => void
}
export const useBronStore = create<BronStore>((set) => ({
isOpen: false,
screen: 'start',
formData: {},
open: (screen) =>
set((state) => ({
...state,
isOpen: true,
screen,
})),
close: () => set({ isOpen: false, screen: 'start', formData: {}, }),
goTo: (screen) => set({ screen }),
setFormData: (data) =>
set((state) => ({ formData: { ...state.formData, ...data } })),
}))
+664
View File
@@ -0,0 +1,664 @@
import styled from 'styled-components';
import { lightGreenColor, darkGreenColor, blackColor, modalColor, whiteColor } from './colors';
export const AdminContainer = styled.div`
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 100%);
color: ${whiteColor};
padding: 0;
* {
box-sizing: border-box;
}
`;
export const AdminContent = styled.div`
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
@media (max-width: 768px) {
padding: 1rem;
}
`;
export const AdminHeaderStyled = styled.nav`
background: rgba(15, 15, 23, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(109, 103, 148, 0.2);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 100;
`;
export const AdminHeaderContent = styled.div`
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
@media (max-width: 768px) {
padding: 0 1rem;
flex-direction: column;
gap: 1rem;
}
`;
export const AdminLogo = styled.a`
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(90deg, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
`;
export const AdminNav = styled.ul`
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 1.5rem;
flex-wrap: wrap;
@media (max-width: 768px) {
gap: 0.75rem;
justify-content: center;
}
`;
export const AdminNavLink = styled.a<{ $active?: boolean }>`
color: ${({ $active }) => ($active ? lightGreenColor : modalColor)};
text-decoration: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.2s;
position: relative;
&:hover {
color: ${lightGreenColor};
background: rgba(76, 230, 94, 0.1);
}
${({ $active }) => $active && `
background: rgba(76, 230, 94, 0.15);
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 2px;
background: ${lightGreenColor};
}
`}
`;
export const PageTitle = styled.h1`
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 2rem;
background: linear-gradient(90deg, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@media (max-width: 768px) {
font-size: 2rem;
margin-bottom: 1.5rem;
}
`;
export const FiltersContainer = styled.div`
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
gap: 1rem;
}
`;
export const FilterGroup = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 200px;
@media (max-width: 768px) {
width: 100%;
}
`;
export const FilterLabel = styled.label`
font-size: 0.875rem;
color: ${modalColor};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
`;
export const FilterInput = styled.input`
background: rgba(15, 15, 23, 0.8);
border: 1px solid rgba(109, 103, 148, 0.3);
border-radius: 12px;
padding: 0.75rem 1rem;
color: ${whiteColor};
font-size: 1rem;
transition: all 0.2s;
&:focus {
outline: none;
border-color: ${lightGreenColor};
background: rgba(15, 15, 23, 0.95);
box-shadow: 0 0 0 3px rgba(76, 230, 94, 0.1);
}
&::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.7;
}
`;
export const FilterSelect = styled.select`
background: rgba(15, 15, 23, 0.8);
border: 1px solid rgba(109, 103, 148, 0.3);
border-radius: 12px;
padding: 0.75rem 1rem;
color: ${whiteColor};
font-size: 1rem;
transition: all 0.2s;
cursor: pointer;
&:focus {
outline: none;
border-color: ${lightGreenColor};
background: rgba(15, 15, 23, 0.95);
box-shadow: 0 0 0 3px rgba(76, 230, 94, 0.1);
}
option {
background: ${blackColor};
color: ${whiteColor};
}
`;
export const RoomCard = styled.div`
background: rgba(15, 15, 23, 0.6);
border: 1px solid rgba(109, 103, 148, 0.2);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 2rem;
backdrop-filter: blur(10px);
transition: all 0.3s;
&:hover {
border-color: rgba(76, 230, 94, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
`;
export const RoomCardHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(109, 103, 148, 0.2);
`;
export const RoomCardTitle = styled.h2`
font-size: 1.5rem;
font-weight: 600;
color: ${whiteColor};
margin: 0;
`;
export const RoomCapacity = styled.div`
font-size: 0.875rem;
color: ${modalColor};
background: rgba(109, 103, 148, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
`;
export const TimeGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
@media (max-width: 768px) {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
`;
export const TimeSlot = styled.div<{ $available: boolean; $booked: number; $capacity: number }>`
background: ${({ $available, $booked, $capacity }) => {
if ($booked === 0) return 'rgba(76, 230, 94, 0.1)';
if ($booked >= $capacity) return 'rgba(220, 53, 69, 0.2)';
return 'rgba(255, 193, 7, 0.15)';
}};
border: 1px solid ${({ $available, $booked, $capacity }) => {
if ($booked === 0) return 'rgba(76, 230, 94, 0.3)';
if ($booked >= $capacity) return 'rgba(220, 53, 69, 0.4)';
return 'rgba(255, 193, 7, 0.4)';
}};
border-radius: 12px;
padding: 1rem;
transition: all 0.2s;
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
`;
export const TimeSlotHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
`;
export const TimeSlotTime = styled.div`
font-size: 1rem;
font-weight: 600;
color: ${whiteColor};
`;
export const TimeSlotCount = styled.div<{ $full: boolean }>`
font-size: 0.75rem;
color: ${({ $full }) => ($full ? '#dc3545' : '#4ce65e')};
font-weight: 600;
`;
export const TimeSlotBookings = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 40px;
`;
export const BookingBadge = styled.span<{ $status: string }>`
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
background: ${({ $status }) => {
if ($status === 'confirmed') return 'rgba(40, 167, 69, 0.3)';
if ($status === 'cancelled') return 'rgba(220, 53, 69, 0.3)';
return 'rgba(255, 193, 7, 0.3)';
}};
color: ${({ $status }) => {
if ($status === 'confirmed') return '#28a745';
if ($status === 'cancelled') return '#dc3545';
return '#ffc107';
}};
border: 1px solid ${({ $status }) => {
if ($status === 'confirmed') return 'rgba(40, 167, 69, 0.5)';
if ($status === 'cancelled') return 'rgba(220, 53, 69, 0.5)';
return 'rgba(255, 193, 7, 0.5)';
}};
`;
export const BookingsList = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 2rem;
`;
export const BookingCard = styled.div<{ $status: string }>`
background: rgba(15, 15, 23, 0.6);
border: 1px solid ${({ $status }) => {
if ($status === 'confirmed') return 'rgba(40, 167, 69, 0.3)';
if ($status === 'cancelled') return 'rgba(220, 53, 69, 0.3)';
return 'rgba(255, 193, 7, 0.3)';
}};
border-left: 4px solid ${({ $status }) => {
if ($status === 'confirmed') return '#28a745';
if ($status === 'cancelled') return '#dc3545';
return '#ffc107';
}};
border-radius: 12px;
padding: 1.5rem;
transition: all 0.2s;
&:hover {
transform: translateX(4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
`;
export const BookingCardHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
`;
export const BookingCardInfo = styled.div`
flex: 1;
min-width: 200px;
`;
export const BookingCardTitle = styled.h3`
font-size: 1.25rem;
font-weight: 600;
color: ${whiteColor};
margin: 0 0 0.5rem 0;
`;
export const BookingCardMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: ${modalColor};
margin-top: 0.5rem;
`;
export const BookingCardActions = styled.div`
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
`;
export const StatusSelect = styled.select<{ $status: string }>`
background: rgba(15, 15, 23, 0.8);
border: 1px solid ${({ $status }) => {
if ($status === 'confirmed') return 'rgba(40, 167, 69, 0.5)';
if ($status === 'cancelled') return 'rgba(220, 53, 69, 0.5)';
return 'rgba(255, 193, 7, 0.5)';
}};
border-radius: 8px;
padding: 0.5rem 1rem;
color: ${whiteColor};
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
&:focus {
outline: none;
border-color: ${lightGreenColor};
box-shadow: 0 0 0 3px rgba(76, 230, 94, 0.1);
}
option {
background: ${blackColor};
color: ${whiteColor};
}
`;
export const DeleteButton = styled.button`
background: rgba(220, 53, 69, 0.2);
border: 1px solid rgba(220, 53, 69, 0.4);
color: #dc3545;
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(220, 53, 69, 0.3);
border-color: #dc3545;
transform: translateY(-1px);
}
`;
export const EmptyState = styled.div`
text-align: center;
padding: 4rem 2rem;
color: ${modalColor};
`;
export const EmptyStateIcon = styled.div`
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
`;
export const EmptyStateText = styled.p`
font-size: 1.125rem;
margin: 0;
`;
export const LoadingSpinner = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 2rem;
`;
export const Spinner = styled.div`
width: 48px;
height: 48px;
border: 4px solid rgba(109, 103, 148, 0.2);
border-top-color: ${lightGreenColor};
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
to { transform: rotate(360deg); }
}
`;
export const SectionTitle = styled.h2`
font-size: 1.5rem;
font-weight: 600;
color: ${whiteColor};
margin: 2rem 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(109, 103, 148, 0.2);
`;
export const AddButton = styled.button`
background: linear-gradient(90deg, ${lightGreenColor}, ${darkGreenColor});
border: none;
color: ${blackColor};
padding: 0.75rem 1.5rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(76, 230, 94, 0.3);
}
&:active {
transform: translateY(0);
}
`;
export 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;
`;
export 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: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(109, 103, 148, 0.5);
border-radius: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
`;
export const ModalHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(109, 103, 148, 0.2);
`;
export const ModalTitle = styled.h3`
font-size: 1.5rem;
color: ${whiteColor};
margin: 0;
background: linear-gradient(90deg, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
`;
export const ModalCloseButton = 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};
}
`;
export const ModalBody = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
`;
export const FormRow = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
`;
export const FormLabel = styled.label`
display: block;
font-size: 0.875rem;
color: ${modalColor};
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
`;
export const FormInput = styled(FilterInput)`
width: 100%;
`;
export const FormSelect = styled(FilterSelect)`
width: 100%;
`;
export const ModalFooter = styled.div`
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(109, 103, 148, 0.2);
`;
export 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};
}
`;
export const SubmitButton = styled.button`
background: linear-gradient(90deg, ${lightGreenColor}, ${darkGreenColor});
border: none;
color: ${blackColor};
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(76, 230, 94, 0.3);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
`;
export const ErrorMessage = styled.div`
background: rgba(220, 53, 69, 0.2);
border: 1px solid rgba(220, 53, 69, 0.4);
color: #dc3545;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
margin-top: 0.5rem;
`;
+9
View File
@@ -0,0 +1,9 @@
export const lightGreenColor = '#4CE65E';
export const darkGreenColor = '#2D5920';
export const whiteColor = '#fff';
export const inactiveTextColor = '#afafaf';
export const inactiveBorderColor = '#3C3C3C';
export const blackColor = '#0E0E17';
export const modalColor = '#6D6794';
export const gradient = `linear-gradient(90deg, ${lightGreenColor}, ${darkGreenColor})`
+474
View File
@@ -0,0 +1,474 @@
import {blackColor, darkGreenColor, gradient, lightGreenColor, modalColor, whiteColor} from "@/styles/colors";
import styled from "styled-components";
import {IMaskInput} from "react-imask";
export const Overlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
`;
export const ModalWrapper = styled.div<{ $animateIn: boolean }>`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background: ${blackColor};
color: white;
padding: 32px;
border-radius: 20px;
width: 70vw;
height: 94vh;
position: relative;
opacity: ${({ $animateIn }) => ($animateIn ? 1 : 0)};
transform: ${({ $animateIn }) =>
$animateIn ? 'translateY(0)' : 'translateY(-20px)'};
transition: opacity 0.3s ease, transform 0.3s ease;
@media (max-height: 950px) {
height: 100vh;
width: 80vw;
.marginTitle{
margin-top: 0 !important;
}
}
@media (max-width: 800px) {
height: 100vh;
width:100vw;
border-radius: 0;
.marginTitle{
margin-top: 16px !important;
}
}
@media(max-width: 450px) {
.marginTitle{
margin-top: 0 !important;
}
.contacts{
gap: 4px;
margin: 10px 0 20px;
img{
width: 40px;
height: 40px;
}
}
.readyText{
font-size: 20px !important;
margin-bottom: 30px !important;
}
}
`;
export const CloseButton = styled.button`
position: absolute;
top: 3.5%;
right: 3.5%;
font-size: 42px;
background: transparent;
border: none;
color: ${modalColor};
cursor: pointer;
@media (max-width: 800px) {
top: 0;
right: 2%;
}
@media (max-height: 685px) {
top: 24px;
}
@media (max-width: 450px) {
top: 18px;
right: 4%;
}
`;
export const SectionTitle = styled.h2`
font-size: 26px;
margin-bottom: 20px;
@media (max-width: 900px) {
font-size: 24px;
text-align: center;
}
@media (max-width: 500px) {
width: 80%;
}
@media (max-width: 450px) {
font-size: 20px;
margin-bottom: 8px;
}
`;
export const Input = styled.input`
border-radius: 15px;
margin-bottom: 20px;
border: 2px solid ${modalColor};
color: ${modalColor};
padding: 0 25px;
max-width: 400px;
width: 40%;
height: 60px;
font-size: 24px;
text-transform: uppercase;
line-height: 60px;
@media (max-width: 580px) {
width: 90%;
}
@media (max-width: 450px) {
height: 50px;
font-size: 18px;
}
`;
export const MaskedInput = styled(IMaskInput)`
border-radius: 15px;
margin-bottom: 20px;
border: 2px solid ${modalColor};
color: ${modalColor};
padding: 0 25px;
max-width: 400px;
width: 40%;
height: 60px;
font-size: 24px;
text-transform: uppercase;
background: transparent;
@media (max-width: 580px) {
width: 90%;
}
@media (max-width: 450px) {
font-size: 18px;
height: 50px;
}
`;
export const InputLabel = styled.p`
text-transform: none;
color: ${whiteColor};
max-width: 590px;
font-size: 20px;
margin:10px 0 14px;
text-align: center;
line-height: 1;
@media (max-width: 560px) {
max-width: 90%;
}
@media (max-width: 450px) {
font-size: 18px;
margin:14px 0 8px;
}
`
export const ChoiceBtn = styled.button<{$selected?: boolean}>`
width: 402px;
max-height: 120px;
padding: 30px 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-transform: uppercase;
color:${modalColor};
position: relative;
border-radius: 15px;
background: ${blackColor};
h3{
font-size: 40px;
background: linear-gradient(to right, ${lightGreenColor}, ${darkGreenColor});
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
max-width: 300px;
}
span{
font-size: 24px;
background: ${({$selected})=> ($selected ? gradient : modalColor)};
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
&::after{
content: "";
position: absolute;
top: -3%;
left: -0.5%;
width: 101%;
height: 106%;
background: ${({$selected})=>($selected ? gradient : modalColor)};
z-index: -1;
border-radius: 15px;
}
@media (max-width: 1220px) {
width: 300px;
padding: 20px 0;
}
@media (max-width: 700px) {
width: 96%;
padding: 16px 0;
margin-top: 4px;
}
@media (max-width: 450px) {
width: 96%;
padding: 10px 8px;
margin-top: 4px;
h3{
font-size: 24px;
margin: 0 0 6px;
}
span{
font-size: 18px;
line-height: 1;
}
}
`
export const TwoColumns = styled.div`
display: grid;
grid-template-columns: repeat(2, 400px);
justify-content: center;
align-items: start;
gap: 30px 28px;
margin: 20px 0 50px;
@media (max-width: 1220px) {
grid-template-columns: repeat(2, 300px);
}
@media (max-width: 700px) {
display: flex;
flex-direction: column;
width: 90%;
height: 400px;
overflow-y: auto;
padding-right: 8px;
justify-content: flex-start;
align-items: center;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #999;
border-radius: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
@media (max-width: 450px) {
margin: 20px 0 20px;
gap: 16px 28px;
height: 300px;
.big_btn{
span{
padding: 12px 14px;
font-size: 24px;
}
}
}
`
export const TitleModal = styled.h2`
font-size: 48px;
text-align: center;
color: ${whiteColor};
line-height: 1;
margin-bottom: 24px;
position: absolute;
top: 60px;
span{
color: ${lightGreenColor};
}
@media (max-height: 950px) {
position: relative;
top: 0;
}
@media (max-width: 900px) {
font-size: 38px;
margin-top: 20px;
}
@media (max-width: 450px) {
font-size: 24px;
margin-top: 42px;
margin-bottom: 12px;
}
`
export const StepsModal = styled.div`
display: flex;
justify-content: center;
align-items: center;
gap: 80px;
margin-bottom: 60px;
position: absolute;
top: 130px;
p{
color: ${modalColor};
font-size: 32px;
position: relative;
width: 40.5px;
text-align: center;
&::after{
content: "";
position: absolute;
bottom: -30%;
left: 50%;
transform: translateX(-50%);
background: ${modalColor};
height:6px;
width: 100px;
}
}
span{
position: relative;
&::after{
content: "";
position: absolute;
bottom: -56%;
left: 50%;
transform: translateX(-50%);
background: ${lightGreenColor};
height:6px;
width: 100px;
}
}
@media (max-height: 950px) {
position: relative;
margin-bottom: 40px;
top: 0;
}
@media (max-width: 900px) {
gap: 50px;
p{
font-size: 28px;
width: 30px;
&::after{
width: 60px;
}
}
img{
width: 30px;
}
span{
&::after{
width: 60px;
}
}
}
@media (max-width: 520px) {
display: none;
}
`
export const BackBtn = styled.button`
position: absolute;
top: 4%;
left: 4%;
font-size: 24px;
color: ${modalColor};
text-transform: uppercase;
@media (max-width: 800px) {
top: 2%;
left: 2%;
}
@media (max-height: 685px) {
top: 44px;
}
@media (max-width: 450px) {
top: 32px;
left: 4%;
}
`
export const BronBtn = styled.button`
position: absolute;
bottom: 4%;
left: 4%;
font-size: 24px;
color: ${whiteColor};
padding: 16px 50px;
background: linear-gradient(to right, #6C6693, #272535);
text-transform: uppercase;
border-radius: 15px;
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 90%;
height: 50%;
background: ${whiteColor};
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: all 0.4s ease;
border-radius: 50%;
filter: blur(20px);
z-index: -1;
}
&:hover::before {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
@media (max-width: 900px) {
display: none;
}
`
export const TimeSlotGrid = styled.div`
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
max-width: 600px;
width: 100%;
margin: 20px 0;
max-height: 400px;
overflow-y: auto;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #999;
border-radius: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
@media (max-width: 700px) {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
@media (max-width: 450px) {
grid-template-columns: repeat(2, 1fr);
gap: 6px;
max-height: 300px;
}
`
export const TimeSlotBtn = styled.button<{$selected?: boolean; $available?: boolean}>`
padding: 12px 16px;
border-radius: 12px;
border: 2px solid ${({$available}) => ($available ? modalColor : inactiveBorderColor)};
background: ${({$selected, $available}) =>
$selected ? gradient :
$available ? 'transparent' :
'rgba(60, 60, 60, 0.3)'};
color: ${({$selected, $available}) =>
$selected ? whiteColor :
$available ? modalColor :
inactiveTextColor};
font-size: 18px;
font-weight: 600;
cursor: ${({$available}) => ($available ? 'pointer' : 'not-allowed')};
transition: all 0.2s ease;
text-transform: uppercase;
&:hover {
${({$available}) => $available && `
transform: scale(1.05);
border-color: ${lightGreenColor};
`}
}
@media (max-width: 450px) {
padding: 10px 12px;
font-size: 16px;
}
`
+90
View File
@@ -0,0 +1,90 @@
/* Reset and base styles */
* {
padding: 0px;
margin: 0px;
border: none;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Links */
a, a:link, a:visited {
text-decoration: none;
}
a:hover {
text-decoration: none;
}
/* Common */
aside, nav, footer, header, section, main {
display: block;
}
h1, h2, h3, h4, h5, h6, p {
font-size: inherit;
font-weight: inherit;
}
ul, ul li {
list-style: none;
}
img {
vertical-align: top;
}
img, svg {
max-width: 100%;
height: auto;
}
address {
font-style: normal;
}
/* Form */
input, textarea, button, select {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: transparent;
}
input::-ms-clear {
display: none;
}
textarea:focus, input:focus {outline: none;}
button, input[type="submit"] {
display: inline-block;
box-shadow: none;
background-color: transparent;
background: none;
cursor: pointer;
}
input:focus, input:active,
button:focus, button:active {
outline: none;
}
button::-moz-focus-inner {
padding: 0;
border: 0;
}
label {
cursor: pointer;
}
legend {
display: block;
}