modified: main_dc/yalarba/easySite/easySite/app/components/forms/LoginForm.vue
modified: main_dc/yalarba/easySite/easySite/app/components/forms/ObjectForm.vue modified: main_dc/yalarba/easySite/easySite/app/components/layout/Header.vue modified: main_dc/yalarba/easySite/easySite/app/components/objects/ObjectCard.vue new file: main_dc/yalarba/easySite/easySite/app/composables/objects/TourCard.vue modified: main_dc/yalarba/easySite/easySite/app/composables/useAuth.ts modified: main_dc/yalarba/easySite/easySite/app/composables/useObjects.ts modified: main_dc/yalarba/easySite/easySite/app/layouts/auth.vue modified: main_dc/yalarba/easySite/easySite/app/layouts/default.vue modified: main_dc/yalarba/easySite/easySite/app/middleware/auth.ts modified: main_dc/yalarba/easySite/easySite/app/pages/auth/login.vue modified: main_dc/yalarba/easySite/easySite/app/pages/index.vue modified: main_dc/yalarba/easySite/easySite/app/pages/objects/[id]/edit.vue modified: main_dc/yalarba/easySite/easySite/app/pages/objects/[id]/index.vue modified: main_dc/yalarba/easySite/easySite/app/pages/objects/create.vue modified: main_dc/yalarba/easySite/easySite/app/pages/objects/index.vue modified: main_dc/yalarba/easySite/easySite/app/pages/objects/my-objects.vue add pages for easysite102.ru and a lot of ather thinks
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Пароль</label>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" class="rounded border-gray-300">
|
||||||
|
<span class="ml-2 text-sm text-gray-600">Запомнить меня</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">
|
||||||
|
Забыли пароль?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<span v-if="loading">Вход...</span>
|
||||||
|
<span v-else>Войти</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(form.value)
|
||||||
|
await navigateTo('/profile')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
// Здесь можно добавить уведомление об ошибке
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-8">
|
||||||
|
<!-- Основная информация -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Основная информация</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Название объекта *</label>
|
||||||
|
<input v-model="form.title" type="text" class="form-input"
|
||||||
|
placeholder="Например: Отель 'Морской бриз'" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Тип объекта *</label>
|
||||||
|
<select v-model="form.type" class="form-select" required>
|
||||||
|
<option value="">Выберите тип</option>
|
||||||
|
<option value="hotel">Отель</option>
|
||||||
|
<option value="apartment">Апартаменты</option>
|
||||||
|
<option value="villa">Вилла</option>
|
||||||
|
<option value="camping">Кемпинг</option>
|
||||||
|
<option value="restaurant">Ресторан</option>
|
||||||
|
<option value="attraction">Достопримечательность</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Описание *</label>
|
||||||
|
<textarea v-model="form.description" rows="4" class="form-input"
|
||||||
|
placeholder="Подробное описание объекта, его преимуществ и особенностей..." required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Местоположение -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Местоположение</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Страна *</label>
|
||||||
|
<input v-model="form.country" type="text" class="form-input" placeholder="Россия" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Город *</label>
|
||||||
|
<input v-model="form.city" type="text" class="form-input" placeholder="Москва" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Категория *</label>
|
||||||
|
<select v-model="form.category" class="form-select" required>
|
||||||
|
<option value="">Выберите категорию</option>
|
||||||
|
<option value="accommodation">Проживание</option>
|
||||||
|
<option value="food">Питание</option>
|
||||||
|
<option value="entertainment">Развлечения</option>
|
||||||
|
<option value="culture">Культура</option>
|
||||||
|
<option value="nature">Природа</option>
|
||||||
|
<option value="shopping">Шоппинг</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Адрес *</label>
|
||||||
|
<input v-model="form.address" type="text" class="form-input" placeholder="ул. Примерная, 123"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Цены и услуги -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Цены и услуги</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Цена *</label>
|
||||||
|
<input v-model="form.price" type="number" class="form-input" placeholder="5000" min="0"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Единица измерения цены *</label>
|
||||||
|
<select v-model="form.priceUnit" class="form-select" required>
|
||||||
|
<option value="">Выберите единицу</option>
|
||||||
|
<option value="per_night">За ночь</option>
|
||||||
|
<option value="per_person">За человека</option>
|
||||||
|
<option value="fixed">Фиксированная цена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Услуги и удобства</label>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-2">
|
||||||
|
<label v-for="amenity in availableAmenities" :key="amenity.value"
|
||||||
|
class="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input type="checkbox" :value="amenity.value" v-model="form.amenities"
|
||||||
|
class="rounded border-gray-300">
|
||||||
|
<span class="text-sm">{{ amenity.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контактная информация -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Контактная информация</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Email для связи *</label>
|
||||||
|
<input v-model="form.contactEmail" type="email" class="form-input"
|
||||||
|
placeholder="contact@example.com" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Телефон для связи *</label>
|
||||||
|
<input v-model="form.contactPhone" type="tel" class="form-input" placeholder="+7 999 123-45-67"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Веб-сайт (необязательно)</label>
|
||||||
|
<input v-model="form.website" type="url" class="form-input" placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопки действий -->
|
||||||
|
<div class="flex justify-end gap-4 pt-6">
|
||||||
|
<button type="button" @click="$emit('cancel')" class="btn btn-outline" :disabled="props.loading">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="props.loading">
|
||||||
|
<span v-if="props.loading">Сохранение...</span>
|
||||||
|
<span v-else>{{ props.object ? 'Обновить' : 'Создать' }} объект</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ObjectItem } from '~/composables/useObjects'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
object?: ObjectItem | null
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', data: any): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Доступные услуги
|
||||||
|
const availableAmenities = [
|
||||||
|
{ value: 'wifi', label: 'Wi-Fi' },
|
||||||
|
{ value: 'parking', label: 'Парковка' },
|
||||||
|
{ value: 'breakfast', label: 'Завтрак' },
|
||||||
|
{ value: 'pool', label: 'Бассейн' },
|
||||||
|
{ value: 'spa', label: 'СПА' },
|
||||||
|
{ value: 'gym', label: 'Тренажерный зал' },
|
||||||
|
{ value: 'air_conditioning', label: 'Кондиционер' },
|
||||||
|
{ value: 'heating', label: 'Отопление' },
|
||||||
|
{ value: 'kitchen', label: 'Кухня' },
|
||||||
|
{ value: 'washing_machine', label: 'Стиральная машина' },
|
||||||
|
{ value: 'tv', label: 'Телевизор' },
|
||||||
|
{ value: 'elevator', label: 'Лифт' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Форма данных
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: '',
|
||||||
|
category: '',
|
||||||
|
address: '',
|
||||||
|
city: '',
|
||||||
|
country: '',
|
||||||
|
price: 0,
|
||||||
|
priceUnit: '',
|
||||||
|
amenities: [] as string[],
|
||||||
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
website: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Заполняем форму данными при редактировании
|
||||||
|
watch(() => props.object, (object) => {
|
||||||
|
if (object) {
|
||||||
|
form.value = {
|
||||||
|
title: object.title,
|
||||||
|
description: object.description,
|
||||||
|
type: object.type,
|
||||||
|
category: object.category,
|
||||||
|
address: object.address,
|
||||||
|
city: object.city,
|
||||||
|
country: object.country,
|
||||||
|
price: object.price,
|
||||||
|
priceUnit: object.priceUnit,
|
||||||
|
amenities: object.amenities || [],
|
||||||
|
contactEmail: object.contactEmail,
|
||||||
|
contactPhone: object.contactPhone,
|
||||||
|
website: object.website || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Валидация
|
||||||
|
if (!form.value.title || !form.value.type || !form.value.description) {
|
||||||
|
alert('Пожалуйста, заполните все обязательные поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', form.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div class="container">
|
||||||
|
<div class="flex justify-between items-center py-4">
|
||||||
|
<!-- Логотип -->
|
||||||
|
<NuxtLink to="/" class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
|
||||||
|
<span class="text-white font-bold text-lg">T</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-2xl">TravelEasy</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Навигация -->
|
||||||
|
<nav class="hidden md:flex gap-6">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: $route.path === '/' }"
|
||||||
|
>
|
||||||
|
Главная
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/objects"
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: $route.path.startsWith('/objects') }"
|
||||||
|
>
|
||||||
|
Объекты
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/objects/my-objects"
|
||||||
|
class="nav-link"
|
||||||
|
>
|
||||||
|
Мои объекты
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/objects/create"
|
||||||
|
class="nav-link"
|
||||||
|
>
|
||||||
|
Добавить объект
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Действия пользователя -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<NuxtLink to="/objects/create" class="btn btn-primary hidden sm:block">
|
||||||
|
Добавить объект
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<NuxtLink to="/auth/login" class="btn btn-outline">
|
||||||
|
Войти
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/auth/register" class="btn btn-primary">
|
||||||
|
Регистрация
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Убрана проверка авторизации - все страницы доступны
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card cursor-pointer hover:shadow-lg transition-shadow" @click="$emit('click')">
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
:src="object.images[0] || '/images/placeholder.jpg'"
|
||||||
|
:alt="object.title"
|
||||||
|
class="w-full h-48 object-cover"
|
||||||
|
>
|
||||||
|
<div class="absolute top-3 right-3">
|
||||||
|
<span class="badge badge-primary">
|
||||||
|
{{ getTypeLabel(object.type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="font-semibold text-lg mb-2 line-clamp-2">{{ object.title }}</h3>
|
||||||
|
<p class="text-gray-600 mb-3 flex items-center gap-1">
|
||||||
|
📍 {{ object.city }}, {{ object.country }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||||
|
{{ object.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1 mb-4">
|
||||||
|
<span
|
||||||
|
v-for="amenity in object.amenities.slice(0, 3)"
|
||||||
|
:key="amenity"
|
||||||
|
class="badge badge-secondary text-xs"
|
||||||
|
>
|
||||||
|
{{ getAmenityLabel(amenity) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="object.amenities.length > 3"
|
||||||
|
class="badge badge-outline text-xs"
|
||||||
|
>
|
||||||
|
+{{ object.amenities.length - 3 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-lg font-semibold text-primary-600">
|
||||||
|
{{ formatPrice(object.price) }}
|
||||||
|
<span class="text-sm text-gray-500">{{ getPriceUnitLabel(object.priceUnit) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="object.rating" class="flex items-center gap-1">
|
||||||
|
<span class="text-yellow-500">⭐</span>
|
||||||
|
<span class="font-medium">{{ object.rating }}</span>
|
||||||
|
<span class="text-gray-500 text-sm">({{ object.reviewCount }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ObjectItem } from '~/composables/useObjects'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
object: ObjectItem
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
hotel: 'Отель',
|
||||||
|
apartment: 'Апартаменты',
|
||||||
|
villa: 'Вилла',
|
||||||
|
camping: 'Кемпинг',
|
||||||
|
restaurant: 'Ресторан',
|
||||||
|
attraction: 'Достопримечательность'
|
||||||
|
}
|
||||||
|
return types[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAmenityLabel = (amenity: string) => {
|
||||||
|
const amenities: Record<string, string> = {
|
||||||
|
wifi: 'Wi-Fi',
|
||||||
|
parking: 'Парковка',
|
||||||
|
breakfast: 'Завтрак',
|
||||||
|
pool: 'Бассейн',
|
||||||
|
spa: 'СПА',
|
||||||
|
kitchen: 'Кухня',
|
||||||
|
washing_machine: 'Стиральная машина'
|
||||||
|
}
|
||||||
|
return amenities[amenity] || amenity
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriceUnitLabel = (unit: string) => {
|
||||||
|
const units: Record<string, string> = {
|
||||||
|
per_night: '/ночь',
|
||||||
|
per_person: '/чел',
|
||||||
|
fixed: ''
|
||||||
|
}
|
||||||
|
return units[unit] || unit
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU').format(price) + ' ₽'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tour-card card">
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
:src="tour.image"
|
||||||
|
:alt="tour.title"
|
||||||
|
class="tour-card__image"
|
||||||
|
>
|
||||||
|
<div class="tour-card__badge">
|
||||||
|
<span class="badge badge-primary">{{ tour.badge }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="font-semibold text-lg mb-2">{{ tour.title }}</h3>
|
||||||
|
<p class="text-gray-600 mb-3">{{ tour.location }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="rating-stars">
|
||||||
|
<span
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
class="rating-star"
|
||||||
|
:class="{ empty: star > Math.round(tour.rating) }"
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
<span class="rating-value">{{ tour.rating }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="tour-card__price">{{ formatPrice(tour.discountPrice || tour.price) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="tour.discountPrice"
|
||||||
|
class="tour-card__discount ml-2"
|
||||||
|
>
|
||||||
|
{{ formatPrice(tour.price) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Tour {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
location: string
|
||||||
|
price: number
|
||||||
|
discountPrice: number | null
|
||||||
|
rating: number
|
||||||
|
image: string
|
||||||
|
badge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tour: Tour
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB'
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginData {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterData {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
passwordConfirmation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
|
// Мок-функция входа
|
||||||
|
const login = async (credentials: LoginData): Promise<User> => {
|
||||||
|
// В реальном приложении здесь будет запрос к API
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (credentials.email === 'user@example.com' && credentials.password === 'password') {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: credentials.email,
|
||||||
|
name: 'Иван Иванов'
|
||||||
|
}
|
||||||
|
user.value = mockUser
|
||||||
|
isAuthenticated.value = true
|
||||||
|
localStorage.setItem('user', JSON.stringify(mockUser))
|
||||||
|
resolve(mockUser)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Неверные учетные данные'))
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мок-функция регистрации
|
||||||
|
const register = async (data: RegisterData): Promise<User> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: Date.now(),
|
||||||
|
email: data.email,
|
||||||
|
name: data.name
|
||||||
|
}
|
||||||
|
user.value = mockUser
|
||||||
|
isAuthenticated.value = true
|
||||||
|
localStorage.setItem('user', JSON.stringify(mockUser))
|
||||||
|
resolve(mockUser)
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выход
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
user.value = null
|
||||||
|
isAuthenticated.value = false
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
resolve()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка авторизации при загрузке
|
||||||
|
const checkAuth = () => {
|
||||||
|
const storedUser = localStorage.getItem('user')
|
||||||
|
if (storedUser) {
|
||||||
|
user.value = JSON.parse(storedUser)
|
||||||
|
isAuthenticated.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
checkAuth
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface ObjectItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
type: 'hotel' | 'apartment' | 'villa' | 'camping' | 'restaurant' | 'attraction'
|
||||||
|
category: string
|
||||||
|
address: string
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
price: number
|
||||||
|
priceUnit: 'per_night' | 'per_person' | 'fixed'
|
||||||
|
amenities: string[]
|
||||||
|
images: string[]
|
||||||
|
contactEmail: string
|
||||||
|
contactPhone: string
|
||||||
|
website?: string
|
||||||
|
coordinates?: {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
}
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
userId: number
|
||||||
|
rating?: number
|
||||||
|
reviewCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useObjects = () => {
|
||||||
|
const objects = ref<ObjectItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Мок-данные объектов
|
||||||
|
const mockObjects: ObjectItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Отель "Морской бриз"',
|
||||||
|
description: 'Комфортабельный отель с видом на море. Идеальное место для отдыха всей семьей.',
|
||||||
|
type: 'hotel',
|
||||||
|
category: 'accommodation',
|
||||||
|
address: 'ул. Приморская, 15',
|
||||||
|
city: 'Сочи',
|
||||||
|
country: 'Россия',
|
||||||
|
price: 5000,
|
||||||
|
priceUnit: 'per_night',
|
||||||
|
amenities: ['wifi', 'parking', 'breakfast', 'pool', 'spa'],
|
||||||
|
images: ['/images/hotel1.jpg', '/images/hotel2.jpg'],
|
||||||
|
contactEmail: 'hotel@example.com',
|
||||||
|
contactPhone: '+7 999 123-45-67',
|
||||||
|
website: 'https://hotel-example.com',
|
||||||
|
coordinates: { lat: 43.5855, lng: 39.7231 },
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-01-15T10:00:00Z',
|
||||||
|
userId: 1,
|
||||||
|
rating: 4.5,
|
||||||
|
reviewCount: 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Апартаменты в центре',
|
||||||
|
description: 'Современные апартаменты в историческом центре города.',
|
||||||
|
type: 'apartment',
|
||||||
|
category: 'accommodation',
|
||||||
|
address: 'ул. Центральная, 25',
|
||||||
|
city: 'Москва',
|
||||||
|
country: 'Россия',
|
||||||
|
price: 3500,
|
||||||
|
priceUnit: 'per_night',
|
||||||
|
amenities: ['wifi', 'kitchen', 'washing_machine'],
|
||||||
|
images: ['/images/apartment1.jpg'],
|
||||||
|
contactEmail: 'apart@example.com',
|
||||||
|
contactPhone: '+7 999 765-43-21',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '2024-01-10T14:30:00Z',
|
||||||
|
updatedAt: '2024-01-12T09:15:00Z',
|
||||||
|
userId: 2,
|
||||||
|
rating: 4.2,
|
||||||
|
reviewCount: 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Получить все объекты
|
||||||
|
const fetchObjects = async (filters?: any): Promise<ObjectItem[]> => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
let filteredObjects = [...mockObjects]
|
||||||
|
|
||||||
|
// Применяем фильтры
|
||||||
|
if (filters) {
|
||||||
|
if (filters.type) {
|
||||||
|
filteredObjects = filteredObjects.filter(obj => obj.type === filters.type)
|
||||||
|
}
|
||||||
|
if (filters.city) {
|
||||||
|
filteredObjects = filteredObjects.filter(obj =>
|
||||||
|
obj.city.toLowerCase().includes(filters.city.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filters.userId) {
|
||||||
|
filteredObjects = filteredObjects.filter(obj => obj.userId === filters.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.value = filteredObjects
|
||||||
|
loading.value = false
|
||||||
|
resolve(filteredObjects)
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить объект по ID
|
||||||
|
const fetchObjectById = async (id: number): Promise<ObjectItem | null> => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const object = mockObjects.find(obj => obj.id === id) || null
|
||||||
|
loading.value = false
|
||||||
|
resolve(object)
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать новый объект
|
||||||
|
const createObject = async (objectData: Omit<ObjectItem, 'id' | 'createdAt' | 'updatedAt'>): Promise<ObjectItem> => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const newObject: ObjectItem = {
|
||||||
|
...objectData,
|
||||||
|
id: Date.now(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
mockObjects.push(newObject)
|
||||||
|
objects.value.push(newObject)
|
||||||
|
loading.value = false
|
||||||
|
resolve(newObject)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Ошибка при создании объекта'
|
||||||
|
loading.value = false
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить объект
|
||||||
|
const updateObject = async (id: number, objectData: Partial<ObjectItem>): Promise<ObjectItem> => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const index = mockObjects.findIndex(obj => obj.id === id)
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Объект не найден')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedObject: ObjectItem = {
|
||||||
|
...mockObjects[index],
|
||||||
|
...objectData,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
mockObjects[index] = updatedObject
|
||||||
|
objects.value[index] = updatedObject
|
||||||
|
loading.value = false
|
||||||
|
resolve(updatedObject)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Ошибка при обновлении объекта'
|
||||||
|
loading.value = false
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}, 800)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить объект
|
||||||
|
const deleteObject = async (id: number): Promise<void> => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = mockObjects.findIndex(obj => obj.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
mockObjects.splice(index, 1)
|
||||||
|
objects.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
resolve()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить объекты текущего пользователя
|
||||||
|
const fetchMyObjects = async (): Promise<ObjectItem[]> => {
|
||||||
|
// В реальном приложении здесь будет userId из авторизации
|
||||||
|
const currentUserId = 1
|
||||||
|
return fetchObjects({ userId: currentUserId })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchObjects,
|
||||||
|
fetchObjectById,
|
||||||
|
createObject,
|
||||||
|
updateObject,
|
||||||
|
deleteObject,
|
||||||
|
fetchMyObjects
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-50">
|
||||||
<h1>Страница</h1>
|
<header class="bg-white shadow-sm">
|
||||||
<p>Содержимое страницы</p>
|
<div class="container py-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<NuxtLink to="/" class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 bg-primary-500 rounded-lg"></div>
|
||||||
|
<span class="font-bold text-xl">TravelEasy</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<NuxtLink to="/" class="nav-link">Главная</NuxtLink>
|
||||||
|
<NuxtLink to="/objects" class="nav-link">Объекты</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Базовый layout для страниц авторизации
|
||||||
|
</script>
|
||||||
@@ -1,54 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-50">
|
||||||
<!-- Header -->
|
<Header />
|
||||||
<header class="bg-white shadow-sm">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center py-4">
|
|
||||||
<NuxtLink to="/" class="text-xl font-bold text-gray-900">
|
|
||||||
EasySite
|
|
||||||
</NuxtLink>
|
|
||||||
<nav class="flex space-x-4">
|
|
||||||
<NuxtLink
|
|
||||||
to="/"
|
|
||||||
class="text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
Главная
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/objects"
|
|
||||||
class="text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
Объекты
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/auth/login"
|
|
||||||
class="text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<main>
|
<main>
|
||||||
<NuxtPage />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<Footer />
|
||||||
<footer class="bg-gray-800 text-white mt-12">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<p>© 2024 EasySite. Все права защищены.</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup lang="ts">
|
||||||
|
// Основной layout с хедером и футером
|
||||||
.router-link-active {
|
</script>
|
||||||
color: #2563eb;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
console.log(to)
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return navigateTo('/auth/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,66 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<div class="max-w-md w-full">
|
||||||
<div>
|
<div class="card">
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<div class="card-header text-center">
|
||||||
Вход в систему
|
<h1 class="text-2xl font-bold">Вход в систему</h1>
|
||||||
</h2>
|
<p class="text-gray-600 mt-2">Введите свои учетные данные</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="mt-8 space-y-6" @submit.prevent="handleLogin">
|
|
||||||
<div>
|
<div class="card-body">
|
||||||
<label for="email" class="sr-only">Email</label>
|
<LoginForm />
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
v-model="form.email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
class="relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
placeholder="Email"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="password" class="sr-only">Пароль</label>
|
<div class="card-footer text-center">
|
||||||
<input
|
<p class="text-gray-600">
|
||||||
id="password"
|
Нет аккаунта?
|
||||||
v-model="form.password"
|
<NuxtLink to="/auth/register" class="text-primary-600 font-medium">
|
||||||
name="password"
|
Зарегистрируйтесь
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
class="relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
placeholder="Пароль"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="text-center">
|
|
||||||
<NuxtLink to="/auth/register" class="text-indigo-600 hover:text-indigo-500">
|
|
||||||
Нет аккаунта? Зарегистрируйтесь
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const form = ref({
|
definePageMeta({
|
||||||
email: '',
|
layout: 'auth'
|
||||||
password: ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
// Логика входа
|
|
||||||
console.log('Login attempt:', form.value)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Стили если нужны */
|
|
||||||
</style>
|
|
||||||
@@ -1,34 +1,93 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<div class="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<!-- Hero Section -->
|
||||||
<div class="text-center">
|
<section class="hero-section">
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-8">
|
<div class="container">
|
||||||
Добро пожаловать в EasySite
|
<div class="relative z-10 py-20">
|
||||||
</h1>
|
<div class="max-w-2xl mx-auto text-center">
|
||||||
<p class="text-xl text-gray-600 mb-8">
|
<h1 class="text-5xl font-bold mb-6">База объектов для путешествий</h1>
|
||||||
Платформа для создания страницы в интернете, продивижения туристических услуг
|
<p class="text-xl mb-8 opacity-90">Добавляйте и находите лучшие места для ваших маршрутов</p>
|
||||||
</p>
|
|
||||||
<div class="space-x-4">
|
<!-- Простой поиск -->
|
||||||
<NuxtLink
|
<div class="max-w-xl mx-auto mb-12">
|
||||||
to="/auth/login"
|
<div class="flex gap-2">
|
||||||
class="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск объектов..."
|
||||||
|
class="form-input flex-1"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
v-model="searchQuery"
|
||||||
>
|
>
|
||||||
|
<button @click="handleSearch" class="btn btn-primary">
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопки авторизации -->
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<NuxtLink to="/auth/login" class="btn btn-outline text-lg px-8 py-3">
|
||||||
Войти
|
Войти
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink to="/auth/register" class="btn btn-primary text-lg px-8 py-3">
|
||||||
to="/auth/register"
|
|
||||||
class="bg-green-600 text-white px-6 py-3 rounded-lg hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Регистрация
|
Регистрация
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Быстрая навигация -->
|
||||||
|
<section class="py-16 bg-white">
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/objects"
|
to="/objects"
|
||||||
class="bg-gray-600 text-white px-6 py-3 rounded-lg hover:bg-gray-700"
|
class="category-card text-center group"
|
||||||
>
|
>
|
||||||
Смотреть объекты
|
<div class="category-card__icon group-hover:bg-primary-200">
|
||||||
|
🔍
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-lg mb-2">Все объекты</h3>
|
||||||
|
<p class="text-sm text-gray-600">Просмотр всей базы объектов</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/objects/my-objects"
|
||||||
|
class="category-card text-center group"
|
||||||
|
>
|
||||||
|
<div class="category-card__icon group-hover:bg-primary-200">
|
||||||
|
📝
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-lg mb-2">Мои объекты</h3>
|
||||||
|
<p class="text-sm text-gray-600">Управление вашими объектами</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/objects/create"
|
||||||
|
class="category-card text-center group"
|
||||||
|
>
|
||||||
|
<div class="category-card__icon group-hover:bg-primary-200">
|
||||||
|
➕
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-lg mb-2">Добавить объект</h3>
|
||||||
|
<p class="text-sm text-gray-600">Создать новую запись</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
navigateTo(`/objects?search=${encodeURIComponent(searchQuery.value)}`)
|
||||||
|
} else {
|
||||||
|
navigateTo('/objects')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,83 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-50 py-8">
|
||||||
<h1>Страница</h1>
|
<div class="container max-w-4xl">
|
||||||
<p>Содержимое страницы</p>
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/objects/${$route.params.id}`"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</NuxtLink>
|
||||||
|
<h1 class="text-3xl font-bold">Редактировать объект</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600">Обновите информацию о вашем объекте</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Загрузка данных объекта...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ObjectForm
|
||||||
|
v-else-if="object"
|
||||||
|
:object="object"
|
||||||
|
:loading="updating"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-12">
|
||||||
|
<div class="text-gray-400 text-6xl mb-4">❌</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">Объект не найден</h3>
|
||||||
|
<p class="text-gray-600 mb-6">Возможно, объект был удален или у вас нет к нему доступа</p>
|
||||||
|
<NuxtLink to="/objects/my-objects" class="btn btn-primary">
|
||||||
|
Вернуться к моим объектам
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { fetchObjectById, updateObject } = useObjects()
|
||||||
|
const object = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const updating = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const objectId = parseInt(route.params.id as string)
|
||||||
|
if (objectId) {
|
||||||
|
const result = await fetchObjectById(objectId)
|
||||||
|
object.value = result
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: any) => {
|
||||||
|
updating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const objectId = parseInt(route.params.id as string)
|
||||||
|
await updateObject(objectId, formData)
|
||||||
|
|
||||||
|
alert('Объект успешно обновлен!')
|
||||||
|
await navigateTo(`/objects/${objectId}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating object:', error)
|
||||||
|
alert('Ошибка при обновлении объекта')
|
||||||
|
} finally {
|
||||||
|
updating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const objectId = route.params.id
|
||||||
|
navigateTo(`/objects/${objectId}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,290 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Хлебные крошки -->
|
||||||
|
<div class="bg-white border-b">
|
||||||
|
<div class="container py-4">
|
||||||
|
<nav class="flex items-center gap-2 text-sm">
|
||||||
|
<NuxtLink to="/objects" class="text-primary-600 hover:text-primary-700">
|
||||||
|
Объекты
|
||||||
|
</NuxtLink>
|
||||||
|
<span class="text-gray-400">/</span>
|
||||||
|
<span class="text-gray-600">{{ object?.title }}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container py-8">
|
||||||
|
<!-- Заголовок и действия -->
|
||||||
|
<div class="flex justify-between items-start mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1>Страница</h1>
|
<h1 class="text-3xl font-bold mb-2">{{ object?.title }}</h1>
|
||||||
<p>Содержимое страницы</p>
|
<div class="flex items-center gap-4 text-gray-600">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
📍 {{ object?.city }}, {{ object?.country }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-primary">
|
||||||
|
{{ getTypeLabel(object?.type || '') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="object?.rating" class="flex items-center gap-1">
|
||||||
|
⭐ {{ object.rating }} ({{ object.reviewCount }} отзывов)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3" v-if="isOwner">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/objects/${object?.id}/edit`"
|
||||||
|
class="btn btn-outline"
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
@click="handleDelete"
|
||||||
|
class="btn btn-outline text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Левая колонка - изображения и описание -->
|
||||||
|
<div class="lg:col-span-2 space-y-8">
|
||||||
|
<!-- Галерея изображений -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="aspect-w-16 aspect-h-9 bg-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="object?.images[0] || '/images/placeholder.jpg'"
|
||||||
|
:alt="object?.title"
|
||||||
|
class="w-full h-96 object-cover"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 gap-2 p-4" v-if="object?.images.length > 1">
|
||||||
|
<img
|
||||||
|
v-for="(image, index) in object.images.slice(1, 5)"
|
||||||
|
:key="index"
|
||||||
|
:src="image"
|
||||||
|
:alt="`${object.title} - изображение ${index + 2}`"
|
||||||
|
class="w-full h-20 object-cover rounded cursor-pointer"
|
||||||
|
@click="currentImage = image"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Описание -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Описание</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-gray-700 leading-relaxed">{{ object?.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Услуги -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Услуги и удобства</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="amenity in object?.amenities"
|
||||||
|
:key="amenity"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 bg-primary-500 rounded-full"></span>
|
||||||
|
<span>{{ getAmenityLabel(amenity) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Правая колонка - информация и контакты -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Карточка цены и бронирования -->
|
||||||
|
<div class="card sticky top-24">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-3xl font-bold text-primary-600 mb-2">
|
||||||
|
{{ formatPrice(object?.price || 0) }}
|
||||||
|
<span class="text-sm font-normal text-gray-500">
|
||||||
|
{{ getPriceUnitLabel(object?.priceUnit || '') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 mt-6">
|
||||||
|
<button class="btn btn-primary w-full">
|
||||||
|
📞 Связаться
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline w-full">
|
||||||
|
📍 Показать на карте
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контактная информация -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="font-semibold">Контактная информация</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-400">📧</span>
|
||||||
|
<a
|
||||||
|
:href="`mailto:${object?.contactEmail}`"
|
||||||
|
class="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{{ object?.contactEmail }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-400">📞</span>
|
||||||
|
<a
|
||||||
|
:href="`tel:${object?.contactPhone}`"
|
||||||
|
class="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{{ object?.contactPhone }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="object?.website"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span class="text-gray-400">🌐</span>
|
||||||
|
<a
|
||||||
|
:href="object.website"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{{ object.website }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-400">📍</span>
|
||||||
|
<span>{{ object?.address }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информация о владельце -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="font-semibold">О владельце</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<span class="text-primary-600 font-semibold">
|
||||||
|
{{ getInitials(object?.userId === 1 ? 'Иван Иванов' : 'Владелец') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ object?.userId === 1 ? 'Иван Иванов' : 'Владелец объекта' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">На сайте с 2024</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ObjectItem } from '~/composables/useObjects'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const { fetchObjectById, deleteObject } = useObjects()
|
||||||
|
|
||||||
|
const object = ref<ObjectItem | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const currentImage = ref('')
|
||||||
|
|
||||||
|
// Проверяем, является ли текущий пользователь владельцем
|
||||||
|
const isOwner = computed(() => {
|
||||||
|
return isAuthenticated && object.value?.userId === 1 // В реальном приложении сравниваем с ID текущего пользователя
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const objectId = parseInt(route.params.id as string)
|
||||||
|
if (objectId) {
|
||||||
|
const result = await fetchObjectById(objectId)
|
||||||
|
object.value = result
|
||||||
|
if (result?.images?.[0]) {
|
||||||
|
currentImage.value = result.images[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
hotel: 'Отель',
|
||||||
|
apartment: 'Апартаменты',
|
||||||
|
villa: 'Вилла',
|
||||||
|
camping: 'Кемпинг',
|
||||||
|
restaurant: 'Ресторан',
|
||||||
|
attraction: 'Достопримечательность'
|
||||||
|
}
|
||||||
|
return types[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAmenityLabel = (amenity: string) => {
|
||||||
|
const amenities: Record<string, string> = {
|
||||||
|
wifi: 'Wi-Fi',
|
||||||
|
parking: 'Парковка',
|
||||||
|
breakfast: 'Завтрак',
|
||||||
|
pool: 'Бассейн',
|
||||||
|
spa: 'СПА',
|
||||||
|
kitchen: 'Кухня',
|
||||||
|
washing_machine: 'Стиральная машина',
|
||||||
|
air_conditioning: 'Кондиционер',
|
||||||
|
heating: 'Отопление',
|
||||||
|
gym: 'Тренажерный зал',
|
||||||
|
tv: 'Телевизор',
|
||||||
|
elevator: 'Лифт'
|
||||||
|
}
|
||||||
|
return amenities[amenity] || amenity
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriceUnitLabel = (unit: string) => {
|
||||||
|
const units: Record<string, string> = {
|
||||||
|
per_night: '/ночь',
|
||||||
|
per_person: '/человека',
|
||||||
|
fixed: ''
|
||||||
|
}
|
||||||
|
return units[unit] || unit
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU').format(price) + ' ₽'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name.split(' ').map(n => n[0]).join('').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!object.value) return
|
||||||
|
|
||||||
|
if (confirm('Вы уверены, что хотите удалить этот объект? Это действие нельзя отменить.')) {
|
||||||
|
try {
|
||||||
|
await deleteObject(object.value.id)
|
||||||
|
alert('Объект успешно удален')
|
||||||
|
await navigateTo('/objects/my-objects')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting object:', error)
|
||||||
|
alert('Ошибка при удалении объекта')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-50 py-8">
|
||||||
<h1>Страница</h1>
|
<div class="container max-w-4xl">
|
||||||
<p>Содержимое страницы</p>
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold">Добавить новый объект</h1>
|
||||||
|
<p class="text-gray-600 mt-2">Заполните информацию о вашем объекте</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ObjectForm
|
||||||
|
:loading="loading"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { createObject } = useObjects()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: any) => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createObject({
|
||||||
|
...formData,
|
||||||
|
userId: 1, // В реальном приложении из авторизации
|
||||||
|
isActive: true,
|
||||||
|
images: formData.images || ['/images/placeholder.jpg'],
|
||||||
|
amenities: formData.amenities || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Показываем уведомление об успехе
|
||||||
|
alert('Объект успешно создан!')
|
||||||
|
await navigateTo('/objects/my-objects')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating object:', error)
|
||||||
|
alert('Ошибка при создании объекта')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigateTo('/objects/my-objects')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,104 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Заголовок и кнопка добавления -->
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1>Страница</h1>
|
<h1 class="text-3xl font-bold">Все объекты</h1>
|
||||||
<p>Содержимое страницы</p>
|
<p class="text-gray-600 mt-2">База объектов для путешествий</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/objects/create" class="btn btn-primary">
|
||||||
|
Добавить объект
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Простой поиск -->
|
||||||
|
<div class="search-filters mb-8">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Поиск по названию, городу или описанию..."
|
||||||
|
@keyup.enter="searchObjects"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button @click="searchObjects" class="btn btn-primary">
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
<button @click="resetSearch" class="btn btn-outline">
|
||||||
|
Сброс
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Быстрые ссылки -->
|
||||||
|
<div class="flex gap-4 mb-6">
|
||||||
|
<NuxtLink to="/objects/my-objects" class="btn btn-outline">
|
||||||
|
📝 Мои объекты
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/objects/create" class="btn btn-outline">
|
||||||
|
➕ Добавить объект
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Результаты поиска -->
|
||||||
|
<div v-if="loading" class="text-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mx-auto"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Загрузка объектов...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="objects.length === 0" class="text-center py-12">
|
||||||
|
<div class="text-gray-400 text-6xl mb-4">🏢</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">Объекты не найдены</h3>
|
||||||
|
<p class="text-gray-600 mb-6">Попробуйте изменить поисковый запрос или добавьте первый объект</p>
|
||||||
|
<NuxtLink to="/objects/create" class="btn btn-primary">
|
||||||
|
Добавить первый объект
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<ObjectCard
|
||||||
|
v-for="object in objects"
|
||||||
|
:key="object.id"
|
||||||
|
:object="object"
|
||||||
|
@click="navigateToObject(object.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ObjectItem } from '~/composables/useObjects'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { objects, loading, fetchObjects } = useObjects()
|
||||||
|
|
||||||
|
const searchQuery = ref(route.query.search as string || '')
|
||||||
|
|
||||||
|
// Загрузка объектов при монтировании
|
||||||
|
onMounted(() => {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
searchObjects()
|
||||||
|
} else {
|
||||||
|
fetchObjects()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchObjects = async () => {
|
||||||
|
await fetchObjects({
|
||||||
|
search: searchQuery.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearch = async () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
await fetchObjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToObject = (id: number) => {
|
||||||
|
navigateTo(`/objects/${id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,182 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Заголовок и статистика -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1>Страница</h1>
|
<h1 class="text-3xl font-bold">Мои объекты</h1>
|
||||||
<p>Содержимое страницы</p>
|
<p class="text-gray-600 mt-2">Управление вашими добавленными местами</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/objects/create" class="btn btn-primary">
|
||||||
|
Добавить объект
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="text-2xl font-bold text-primary-600">{{ stats.total }}</div>
|
||||||
|
<div class="text-gray-600">Всего объектов</div>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ stats.active }}</div>
|
||||||
|
<div class="text-gray-600">Активных</div>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-600">{{ stats.rating }}</div>
|
||||||
|
<div class="text-gray-600">Средний рейтинг</div>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="text-2xl font-bold text-orange-600">{{ stats.reviews }}</div>
|
||||||
|
<div class="text-gray-600">Отзывов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица объектов -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="text-xl font-semibold">Список объектов</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200">
|
||||||
|
<th class="text-left py-3 px-4">Название</th>
|
||||||
|
<th class="text-left py-3 px-4">Тип</th>
|
||||||
|
<th class="text-left py-3 px-4">Город</th>
|
||||||
|
<th class="text-left py-3 px-4">Цена</th>
|
||||||
|
<th class="text-left py-3 px-4">Статус</th>
|
||||||
|
<th class="text-left py-3 px-4">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="object in myObjects"
|
||||||
|
:key="object.id"
|
||||||
|
class="border-b border-gray-100 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<div class="font-medium">{{ object.title }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ formatDate(object.createdAt) }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span class="badge badge-primary">
|
||||||
|
{{ getTypeLabel(object.type) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">{{ object.city }}</td>
|
||||||
|
<td class="py-3 px-4 font-medium">
|
||||||
|
{{ formatPrice(object.price) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="object.isActive ? 'badge-success' : 'badge-secondary'"
|
||||||
|
>
|
||||||
|
{{ object.isActive ? 'Активен' : 'Неактивен' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/objects/${object.id}`"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
Просмотр
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/objects/${object.id}/edit`"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
@click="deleteObject(object.id)"
|
||||||
|
class="btn btn-outline btn-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Пустой state -->
|
||||||
|
<div
|
||||||
|
v-if="!loading && myObjects.length === 0"
|
||||||
|
class="text-center py-12"
|
||||||
|
>
|
||||||
|
<div class="text-gray-400 text-6xl mb-4">🏢</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">У вас пока нет объектов</h3>
|
||||||
|
<p class="text-gray-600 mb-6">Добавьте первый объект, чтобы начать работу</p>
|
||||||
|
<NuxtLink to="/objects/create" class="btn btn-primary">
|
||||||
|
Добавить первый объект
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ObjectItem } from '~/composables/useObjects'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchMyObjects, deleteObject, objects: myObjects, loading } = useObjects()
|
||||||
|
|
||||||
|
// Статистика (мок-данные)
|
||||||
|
const stats = ref({
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchMyObjects()
|
||||||
|
updateStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateStats = () => {
|
||||||
|
stats.value.total = myObjects.value.length
|
||||||
|
stats.value.active = myObjects.value.filter(obj => obj.isActive).length
|
||||||
|
stats.value.reviews = myObjects.value.reduce((sum, obj) => sum + (obj.reviewCount || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
hotel: 'Отель',
|
||||||
|
apartment: 'Апартаменты',
|
||||||
|
villa: 'Вилла',
|
||||||
|
camping: 'Кемпинг',
|
||||||
|
restaurant: 'Ресторан',
|
||||||
|
attraction: 'Достопримечательность'
|
||||||
|
}
|
||||||
|
return types[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU').format(price) + ' ₽'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка удаления объекта
|
||||||
|
const handleDeleteObject = async (id: number) => {
|
||||||
|
if (confirm('Вы уверены, что хотите удалить этот объект?')) {
|
||||||
|
await deleteObject(id)
|
||||||
|
await fetchMyObjects()
|
||||||
|
updateStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user