Files
tp/main_dc/yalarba/easySite/app/pages/objects/[id]/index.vue
T
valitovgaziz 2941b14b38 flatten easySite directory: remove extra easySite/easySite nesting
- Moved contents of main_dc/yalarba/easySite/easySite/ up to easySite/
- Updated docker-compose.yml build context path
- Deleted empty nested easySite/ directory
2026-06-12 11:16:15 +05:00

803 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-wrapper">
<main class="object-page">
<div class="container max-w-6xl">
<nav class="breadcrumbs">
<NuxtLink to="/objects" class="breadcrumb-link">Все объекты</NuxtLink>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{ object?.title || object?.short_name }}</span>
</nav>
<div v-if="loading" class="loading-state">
<div class="loading-spinner"/>
<p class="loading-text">Загрузка объекта...</p>
</div>
<div v-else-if="!object" class="empty-state">
<div class="empty-icon">🏢</div>
<h3 class="empty-title">Объект не найден</h3>
<p class="empty-description">Возможно, объект был удалён или у вас нет к нему доступа</p>
<NuxtLink to="/objects" class="btn btn-primary">Вернуться к списку</NuxtLink>
</div>
<template v-else>
<div class="page-header">
<div class="header-content">
<div class="header-text">
<div class="object-meta">
<span class="object-type">{{ getTypeLabel(object.type) }}</span>
<div class="rating">
<div class="rating-stars">
<span
v-for="star in 5"
:key="star"
class="rating-star"
:class="{ empty: star > Math.round(touristScore) }">
</span>
</div>
<span class="rating-value">{{ touristScore }}</span>
<span class="reviews-count">({{ object.feedback_count }} отзывов)</span>
</div>
</div>
<h1 class="page-title">{{ object.title || object.short_name }}</h1>
<div class="object-location">
<span class="location-icon">📍</span>
{{ object.address }}
</div>
</div>
<div class="header-actions">
<div class="price-section">
<div class="price">{{ formatPrice(object.price) }}</div>
<div class="price-period">{{ pricePeriodLabel }}</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary btn-large" @click="showBookingModal = true">
Забронировать
</button>
</div>
</div>
</div>
</div>
<div class="gallery-section">
<div class="main-image">
<img :src="mainImage" :alt="object.title || object.short_name" class="gallery-image" @click="openGallery(0)">
</div>
<div v-if="object.images && object.images.length > 1" class="thumbnails">
<div
v-for="(image, index) in object.images.slice(1, 5)"
:key="image.id"
class="thumbnail"
@click="openGallery(index + 1)">
<img :src="image.url" :alt="`${object.title} - фото ${index + 2}`">
<div v-if="index === 3 && object.images.length > 5" class="more-images">
+{{ object.images.length - 5 }}
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-main">
<section class="content-section">
<h2 class="section-title">Описание</h2>
<p class="object-description">{{ object.description || object.short_description }}</p>
</section>
<section v-if="object.amenities && object.amenities.length" class="content-section">
<h2 class="section-title">Удобства</h2>
<div class="amenities-grid">
<div v-for="amenity in object.amenities" :key="amenity.id" class="amenity-item">
<span class="amenity-icon">{{ amenity.icon || '✅' }}</span>
<span class="amenity-text">{{ amenity.name }}</span>
</div>
</div>
</section>
<section class="content-section">
<h2 class="section-title">Контакты</h2>
<div class="contact-info">
<div v-if="object.phone" class="contact-item">
<span class="contact-icon">📞</span>
<a :href="`tel:${object.phone}`" class="contact-link">{{ object.phone }}</a>
</div>
<div v-if="object.email" class="contact-item">
<span class="contact-icon"></span>
<a :href="`mailto:${object.email}`" class="contact-link">{{ object.email }}</a>
</div>
<div v-if="object.site" class="contact-item">
<span class="contact-icon">🌐</span>
<a :href="object.site" target="_blank" class="contact-link">{{ object.site }}</a>
</div>
<div v-if="object.address" class="contact-item">
<span class="contact-icon">📍</span>
<span class="contact-text">{{ object.address }}</span>
</div>
</div>
</section>
</div>
<div class="content-sidebar">
<div class="booking-card card">
<div class="card-header">
<h3 class="card-title">Бронирование</h3>
<div class="price-info">
<span class="price-large">{{ formatPrice(object.price) }}</span>
<span class="price-period">{{ pricePeriodLabel }}</span>
</div>
</div>
<div class="card-body">
<div class="booking-form">
<div class="form-group">
<label class="form-label">Даты</label>
<div class="date-inputs">
<input v-model="bookingDates.checkIn" type="date" class="form-input" placeholder="Заезд">
<input v-model="bookingDates.checkOut" type="date" class="form-input" placeholder="Выезд">
</div>
</div>
<div class="form-group">
<label class="form-label">Гости</label>
<select v-model="bookingGuests" class="form-select">
<option value="1">1 гость</option>
<option value="2">2 гостя</option>
<option value="3">3 гостя</option>
<option value="4">4 гостя</option>
</select>
</div>
<button
class="btn btn-primary btn-large"
:disabled="!bookingDates.checkIn || !bookingDates.checkOut"
@click="showBookingModal = true">
Забронировать
</button>
</div>
</div>
</div>
<div class="contact-card card">
<div class="card-header">
<h3 class="card-title">Контактная информация</h3>
</div>
<div class="card-body">
<div class="contact-actions">
<a v-if="object.phone" :href="`tel:${object.phone}`" class="btn btn-outline btn-with-icon">
<span>📞</span>
Позвонить
</a>
<a v-if="object.email" :href="`mailto:${object.email}`" class="btn btn-outline btn-with-icon">
<span></span>
Написать
</a>
</div>
</div>
</div>
<div v-if="isOwner" class="owner-actions card">
<div class="card-header">
<h3 class="card-title">Управление объектом</h3>
</div>
<div class="card-body">
<div class="action-buttons">
<NuxtLink :to="`/objects/${object.id}/edit`" class="btn btn-outline btn-with-icon">
<span></span>
Редактировать
</NuxtLink>
<button
class="btn btn-outline btn-with-icon"
:class="{ 'btn-primary': !object.is_active }"
@click="toggleObjectStatus">
<span>{{ object.is_active ? '⏸️' : '▶️' }}</span>
{{ object.is_active ? 'Деактивировать' : 'Активировать' }}
</button>
<button
class="btn btn-outline btn-with-icon delete-btn"
@click="deleteObject">
<span>🗑</span>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import type { ObjectResponse } from '~/types/objects'
const route = useRoute()
const { getById, remove } = useObjects()
const { user } = useAuth()
const object = ref<ObjectResponse | null>(null)
const loading = ref(true)
const showBookingModal = ref(false)
const showGallery = ref(false)
const galleryIndex = ref(0)
const bookingDates = ref({ checkIn: '', checkOut: '' })
const bookingGuests = ref('2')
const touristScore = computed(() => {
return object.value?.feedback_count || 0
})
const mainImage = computed(() => {
if (object.value?.images?.length) {
return object.value.images[0].url
}
return '/images/placeholder.jpg'
})
const pricePeriodLabel = computed(() => {
const labels: Record<string, string> = {
per_night: 'за ночь',
per_person: 'за человека',
per_tour: 'за тур',
per_hour: 'за час'
}
const p = object.value?.price_period
return p ? labels[p] || p : ''
})
const isOwner = computed(() => {
if (!user.value || !object.value) return false
return user.value.id === object.value.owner_id
})
onMounted(async () => {
loading.value = true
try {
const id = parseInt(route.params.id as string)
object.value = await getById(id)
useSeoMeta({
title: `${object.value.title || object.value.short_name} - EasySite`,
description: object.value.description || object.value.short_description,
ogTitle: object.value.title || object.value.short_name,
ogDescription: object.value.description || object.value.short_description,
ogImage: mainImage.value
})
} catch (error) {
console.error('Error loading object:', error)
object.value = null
} finally {
loading.value = false
}
})
const getTypeLabel = (type: string | undefined) => {
const types: Record<string, string> = {
hotel: '🏨 Гостиница',
sanatorium: '🏥 Санаторий',
guest_house: '🏡 Гостевой дом',
tour: '🧳 Тур',
restaurant: '🍴 Ресторан'
}
return types[type || ''] || type || ''
}
const formatPrice = (price: number | undefined) => {
if (!price && price !== 0) return '—'
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(price)
}
const openGallery = (index: number) => {
galleryIndex.value = index
showGallery.value = true
}
const toggleObjectStatus = async () => {
if (!object.value) return
// TODO: Implement status toggle via update API
object.value.is_active = !object.value.is_active
}
const deleteObject = async () => {
if (!object.value) return
if (!confirm('Вы уверены, что хотите удалить этот объект?')) return
try {
await remove(object.value.id)
await navigateTo('/objects/my-objects')
} catch (error) {
console.error('Error deleting object:', error)
alert('Ошибка при удалении объекта')
}
}
const handleBooking = () => {
showBookingModal.value = false
}
</script>
<style scoped>
.object-page {
padding: var(--space-xl) 0;
min-height: calc(100vh - 160px);
background: var(--bg-secondary);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-lg);
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.breadcrumb-link {
color: var(--primary-500);
text-decoration: none;
transition: color 0.3s ease;
}
.breadcrumb-link:hover {
color: var(--primary-600);
text-decoration: underline;
}
.breadcrumb-separator {
color: var(--text-tertiary);
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: var(--font-medium);
}
.page-header {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.header-content {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-xl);
align-items: flex-start;
}
.header-text {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.object-meta {
display: flex;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-sm);
}
.object-type {
padding: var(--space-xs) var(--space-sm);
background: var(--primary-100);
color: var(--primary-700);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.rating {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.rating-stars {
display: flex;
gap: 2px;
}
.rating-star {
color: var(--secondary-400);
font-size: var(--text-lg);
}
.rating-star.empty {
color: var(--gray-300);
}
.rating-value {
font-weight: var(--font-semibold);
color: var(--text-primary);
}
.reviews-count {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.page-title {
font-family: var(--font-heading);
font-size: var(--text-4xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
line-height: 1.2;
}
.object-location {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-lg);
color: var(--text-secondary);
}
.location-icon {
font-size: var(--text-xl);
}
.header-actions {
display: flex;
flex-direction: column;
gap: var(--space-lg);
min-width: 280px;
}
.price-section {
text-align: center;
padding: var(--space-lg);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.price {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--primary-600);
line-height: 1;
}
.price-large {
font-family: var(--font-heading);
font-size: var(--text-2xl);
font-weight: var(--font-bold);
color: var(--primary-600);
}
.price-period {
font-size: var(--text-sm);
color: var(--text-tertiary);
margin-top: var(--space-xs);
}
.action-buttons {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.gallery-section {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-sm);
margin-bottom: var(--space-xl);
height: 400px;
}
.main-image {
border-radius: var(--radius-lg);
overflow: hidden;
height: 100%;
}
.gallery-image {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: transform 0.3s ease;
}
.gallery-image:hover {
transform: scale(1.02);
}
.thumbnails {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
height: 100%;
}
.thumbnail {
position: relative;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
height: calc(50% - var(--space-sm) / 2);
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.thumbnail:hover img {
transform: scale(1.05);
}
.more-images {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-bold);
font-size: var(--text-lg);
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-xl);
align-items: start;
}
.content-main {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.content-section {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.section-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-lg);
}
.object-description {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--text-secondary);
margin: 0;
}
.amenities-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
}
.amenity-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.amenity-icon {
font-size: var(--text-lg);
}
.amenity-text {
font-weight: var(--font-medium);
color: var(--text-primary);
}
.contact-info {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.contact-item {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.contact-icon {
font-size: var(--text-lg);
width: 1.5rem;
}
.contact-link {
color: var(--primary-600);
text-decoration: none;
transition: color 0.3s ease;
}
.contact-link:hover {
color: var(--primary-700);
text-decoration: underline;
}
.contact-text {
color: var(--text-primary);
}
.content-sidebar {
display: flex;
flex-direction: column;
gap: var(--space-lg);
position: sticky;
top: var(--space-xl);
}
.booking-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.card-title {
font-family: var(--font-heading);
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
.booking-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.date-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
}
.contact-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.delete-btn {
color: var(--danger-500);
border-color: var(--danger-200);
}
.delete-btn:hover {
background: var(--danger-50);
border-color: var(--danger-500);
}
.loading-state,
.empty-state {
text-align: center;
padding: var(--space-2xl);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-light);
}
.loading-spinner {
width: 3rem;
height: 3rem;
border: 3px solid var(--border-light);
border-top: 3px solid var(--primary-500);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
}
.empty-icon {
font-size: 4rem;
margin-bottom: var(--space-lg);
opacity: 0.7;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 1024px) {
.content-grid {
grid-template-columns: 1fr;
gap: var(--space-lg);
}
.content-sidebar {
position: static;
}
.gallery-section {
height: 300px;
}
}
@media (max-width: 768px) {
.header-content {
grid-template-columns: 1fr;
gap: var(--space-lg);
}
.header-actions {
min-width: auto;
}
.gallery-section {
grid-template-columns: 1fr;
height: auto;
}
.thumbnails {
grid-template-columns: repeat(4, 1fr);
height: 100px;
}
.thumbnail {
height: 100px;
}
.page-title {
font-size: var(--text-2xl);
}
.amenities-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-lg);
}
.object-meta {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
.thumbnails {
grid-template-columns: repeat(2, 1fr);
}
.thumbnail {
height: 80px;
}
.date-inputs {
grid-template-columns: 1fr;
}
}
</style>