feat: create Nuxt 4 SPA for yalarba.ru (yalarba-nuxt)

This commit is contained in:
valitovgaziz
2026-06-12 00:29:24 +05:00
parent 35ba568d97
commit 60867af69c
51 changed files with 22259 additions and 0 deletions
@@ -0,0 +1,5 @@
node_modules/
.nuxt/
.output/
.git/
*.md
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.nuxt/
.output/
dist/
*.local
.env
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
FROM node:24.11.0-alpine
WORKDIR /app
COPY package*.json ./
COPY nuxt.config.ts ./
COPY tsconfig.json ./
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm install --production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+5
View File
@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
@@ -0,0 +1,219 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: var(--font-button);
font-size: var(--font-size-body);
font-weight: var(--font-weight-semibold);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
line-height: 1;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-white);
}
.btn--primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn--secondary {
background: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.btn--secondary:hover:not(:disabled) {
background: var(--color-light-green);
}
.btn--ghost {
background: transparent;
color: var(--color-text-black);
border: 1px solid var(--color-stroke);
}
.btn--ghost:hover:not(:disabled) {
background: var(--color-bg-gray);
}
.btn--danger {
background: var(--color-red);
color: var(--color-text-white);
}
.btn--sm {
padding: 8px 16px;
font-size: var(--font-size-small);
}
.btn--md {
padding: 12px 24px;
}
.btn--lg {
padding: 16px 32px;
}
.btn--full {
width: 100%;
}
.form-input {
width: 100%;
padding: 12px 16px;
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-text-black);
background: var(--color-bg-gray);
border: 1px solid transparent;
border-radius: var(--radius-sm);
outline: none;
transition: all var(--transition-fast);
}
.form-input::placeholder {
color: var(--color-text-gray);
}
.form-input:focus {
border-color: var(--color-primary);
background: var(--color-white);
}
.form-input--error {
border-color: var(--color-red);
background: var(--color-bg-error);
}
.form-label {
display: block;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-text-black);
margin-bottom: 6px;
}
.form-error {
font-family: var(--font-body);
font-size: var(--font-size-caption);
color: var(--color-red);
margin-top: 4px;
}
.form-select {
width: 100%;
padding: 12px 16px;
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-text-black);
background: var(--color-bg-gray) url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%23333333' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 16px center;
border: 1px solid transparent;
border-radius: var(--radius-sm);
outline: none;
appearance: none;
transition: all var(--transition-fast);
}
.form-select:focus {
border-color: var(--color-primary);
background-color: var(--color-white);
}
.card {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
overflow: hidden;
transition: box-shadow var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
font-family: var(--font-body);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
color: var(--color-primary);
background: var(--color-light-green);
border-radius: 20px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-family: var(--font-body);
font-size: 10px;
font-weight: var(--font-weight-bold);
color: var(--color-text-white);
background: var(--color-red);
border-radius: 10px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
object-fit: cover;
background: var(--color-bg-gray);
}
.avatar--sm {
width: 32px;
height: 32px;
}
.avatar--lg {
width: 64px;
height: 64px;
}
.skeleton {
background: linear-gradient(90deg, var(--color-bg-gray) 25%, #e8e8e8 50%, var(--color-bg-gray) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.container {
max-width: var(--container-max);
margin: 0 auto;
padding: 0 var(--container-padding);
width: 100%;
}
.section {
padding: 60px 0;
}
@media (max-width: 744px) {
.section {
padding: 32px 0;
}
}
@@ -0,0 +1,47 @@
@font-face {
font-family: 'BELLABOO';
src: url('/fonts/BELLABOO.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Mulish';
src: url('/fonts/Mulish-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Mulish';
src: url('/fonts/Mulish-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Mulish';
src: url('/fonts/Mulish-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Mulish';
src: url('/fonts/Mulish-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@@ -0,0 +1,60 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-body);
font-size: var(--font-size-body);
font-weight: var(--font-weight-regular);
line-height: 1.5;
color: var(--color-text-black);
background: var(--color-white);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
display: block;
}
ul, ol {
list-style: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
input, textarea, select {
font-family: inherit;
}
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
@@ -0,0 +1,85 @@
h1, .h1 {
font-family: var(--font-display);
font-size: var(--font-size-h1);
font-weight: var(--font-weight-regular);
line-height: 1.1;
color: var(--color-text-black);
}
h2, .h2 {
font-family: var(--font-body);
font-size: var(--font-size-h2);
font-weight: var(--font-weight-bold);
line-height: 1.2;
color: var(--color-text-black);
}
h3, .h3 {
font-family: var(--font-body);
font-size: var(--font-size-h3);
font-weight: var(--font-weight-bold);
line-height: 1.3;
color: var(--color-text-black);
}
h4, .h4 {
font-family: var(--font-body);
font-size: var(--font-size-h4);
font-weight: var(--font-weight-bold);
line-height: 1.3;
color: var(--color-text-black);
}
h5, .h5 {
font-family: var(--font-body);
font-size: var(--font-size-h5);
font-weight: var(--font-weight-semibold);
line-height: 1.4;
color: var(--color-text-black);
}
.body-text {
font-family: var(--font-body);
font-size: var(--font-size-body);
font-weight: var(--font-weight-regular);
line-height: 1.5;
color: var(--color-text-black);
}
.body-text--medium {
font-weight: var(--font-weight-medium);
}
.body-text--semibold {
font-weight: var(--font-weight-semibold);
}
.body-text--gray {
color: var(--color-text-gray);
}
.body-text--white {
color: var(--color-text-white);
}
.small-text {
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: 1.4;
color: var(--color-text-gray);
}
.caption {
font-family: var(--font-body);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-regular);
line-height: 1.3;
color: var(--color-text-gray);
}
@media (max-width: 744px) {
h1, .h1 {
font-size: var(--font-size-h1);
}
}
@@ -0,0 +1,77 @@
:root {
--color-primary: #196533;
--color-primary-hover: #114322;
--color-primary-tapped: #114322;
--color-primary-inactive: #B4B4B4;
--color-light-green: #BEDAC8;
--color-text-black: #333333;
--color-text-gray: #7D7D7D;
--color-text-white: #FFFFFF;
--color-orange: #FF8833;
--color-red: #FF3636;
--color-green-accent: #04BD45;
--color-blue: #0066FF;
--color-status: #00CC99;
--color-stroke: #BAD1C2;
--color-bg-gray: #F2F2F2;
--color-dark: #25282B;
--color-bg-validation: #FFF4EB;
--color-bg-error: #FFEBEB;
--color-bg-success: #E8F0EB;
--color-white: #FFFFFF;
--color-black: #000000;
--shadow-sm: 0px 2px 4px rgba(0, 0, 0, 0.08);
--shadow-md: 0px 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0px 8px 24px rgba(0, 0, 0, 0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 50%;
--font-display: 'BELLABOO', serif;
--font-body: 'Mulish', sans-serif;
--font-button: 'Inter', sans-serif;
--font-size-h1: 70px;
--font-size-h2: 32px;
--font-size-h3: 28px;
--font-size-h4: 24px;
--font-size-h5: 20px;
--font-size-body: 16px;
--font-size-small: 14px;
--font-size-caption: 12px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--container-max: 1440px;
--container-padding: 60px;
--header-height: 80px;
--bottom-nav-height: 64px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
}
@media (max-width: 1024px) {
:root {
--container-padding: 32px;
}
}
@media (max-width: 744px) {
:root {
--container-padding: 16px;
--font-size-h1: 24px;
--font-size-h2: 24px;
--font-size-h3: 22px;
--font-size-h4: 20px;
--font-size-h5: 18px;
--header-height: 56px;
}
}
@@ -0,0 +1,77 @@
<template>
<nav class="bottom-nav">
<NuxtLink to="/" class="bottom-nav__item" :class="{ 'bottom-nav__item--active': $route.path === '/' }">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>Главная</span>
</NuxtLink>
<NuxtLink to="/search" class="bottom-nav__item" :class="{ 'bottom-nav__item--active': $route.path.startsWith('/search') }">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>Поиск</span>
</NuxtLink>
<NuxtLink to="/favorites" class="bottom-nav__item" :class="{ 'bottom-nav__item--active': $route.path === '/favorites' }">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<span>Избранное</span>
</NuxtLink>
<NuxtLink to="/profile" class="bottom-nav__item" :class="{ 'bottom-nav__item--active': $route.path === '/profile' }">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/>
<path d="M4 21v-1a6 6 0 0112 0v1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>Профиль</span>
</NuxtLink>
</nav>
</template>
<style scoped>
.bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--bottom-nav-height);
background: var(--color-white);
border-top: 1px solid var(--color-stroke);
z-index: 100;
padding: 0 8px;
}
@media (max-width: 744px) {
.bottom-nav {
display: flex;
align-items: center;
justify-content: space-around;
}
}
.bottom-nav__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 12px;
color: var(--color-text-gray);
font-size: 10px;
font-family: var(--font-body);
transition: color var(--transition-fast);
}
.bottom-nav__item--active {
color: var(--color-primary);
}
.bottom-nav__item svg {
width: 24px;
height: 24px;
}
</style>
@@ -0,0 +1,125 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer__grid">
<div class="footer__brand">
<img src="/logo.svg" alt="Ял Арба" class="footer__logo" />
<p class="small-text">
Туристическая платформа Республики Башкортостан
</p>
</div>
<div class="footer__col">
<h5 class="footer__title">Навигация</h5>
<ul class="footer__links">
<li><NuxtLink to="/search">Поиск</NuxtLink></li>
<li><NuxtLink to="/about">О нас</NuxtLink></li>
<li><NuxtLink to="/contacts">Контакты</NuxtLink></li>
<li><NuxtLink to="/support">Поддержка</NuxtLink></li>
</ul>
</div>
<div class="footer__col">
<h5 class="footer__title">Пользователю</h5>
<ul class="footer__links">
<li><NuxtLink to="/auth/login">Войти</NuxtLink></li>
<li><NuxtLink to="/auth/register">Регистрация</NuxtLink></li>
<li><NuxtLink to="/favorites">Избранное</NuxtLink></li>
<li><NuxtLink to="/reviews">Отзывы</NuxtLink></li>
</ul>
</div>
<div class="footer__col">
<h5 class="footer__title">Контакты</h5>
<ul class="footer__links">
<li>
<a href="mailto:info@yalarba.ru">info@yalarba.ru</a>
</li>
<li>
<a href="tel:+73472999999">+7 (347) 299-99-99</a>
</li>
</ul>
</div>
</div>
<div class="footer__bottom">
<p class="caption">&copy; {{ year }} Ял Арба. Все права защищены.</p>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const year = new Date().getFullYear()
</script>
<style scoped>
.footer {
background: var(--color-dark);
color: var(--color-text-white);
padding: 48px 0 24px;
}
.footer__grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 40px;
}
.footer__logo {
height: 32px;
margin-bottom: 12px;
filter: brightness(0) invert(1);
}
.footer__title {
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-text-white);
margin-bottom: 16px;
}
.footer__links {
display: flex;
flex-direction: column;
gap: 10px;
}
.footer__links a {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: rgba(255, 255, 255, 0.7);
transition: color var(--transition-fast);
}
.footer__links a:hover {
color: var(--color-text-white);
}
.footer__bottom {
margin-top: 40px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
}
.footer__bottom .caption {
color: rgba(255, 255, 255, 0.5);
}
@media (max-width: 744px) {
.footer {
padding: 32px 0 80px;
}
.footer__grid {
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.footer__brand {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,193 @@
<template>
<header class="header">
<div class="container header__inner">
<NuxtLink to="/" class="header__logo">
<img src="/logo.svg" alt="Ял Арба" class="header__logo-img" />
</NuxtLink>
<nav class="header__nav">
<NuxtLink to="/search" class="header__link">
Поиск
</NuxtLink>
<NuxtLink to="/about" class="header__link">
О нас
</NuxtLink>
<NuxtLink to="/contacts" class="header__link">
Контакты
</NuxtLink>
</nav>
<div class="header__actions">
<template v-if="isAuthenticated">
<NuxtLink to="/favorites" class="header__icon-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
</NuxtLink>
<NuxtLink to="/notifications" class="header__icon-btn header__notif">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9zM13.73 21a1.999 1.999 0 01-3.46 0" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<span v-if="notifCount" class="badge">{{ notifCount }}</span>
</NuxtLink>
<NuxtLink to="/profile" class="header__profile">
<img v-if="user?.avatar" :src="user.avatar" alt="" class="avatar avatar--sm" />
<span v-else class="avatar avatar--sm header__avatar-placeholder">{{ userInitials }}</span>
</NuxtLink>
</template>
<template v-else>
<NuxtLink to="/auth/login" class="btn btn--ghost btn--sm">
Войти
</NuxtLink>
<NuxtLink to="/auth/register" class="btn btn--primary btn--sm">
Регистрация
</NuxtLink>
</template>
</div>
<button class="header__burger" @click="menuOpen = !menuOpen" aria-label="Меню">
<span></span>
<span></span>
<span></span>
</button>
</div>
</header>
</template>
<script setup lang="ts">
const { isAuthenticated, user } = useAuth()
const menuOpen = ref(false)
const notifCount = ref(0)
const userInitials = computed(() => {
if (!user.value?.name) return '?'
return user.value.name.charAt(0).toUpperCase()
})
</script>
<style scoped>
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
background: var(--color-white);
border-bottom: 1px solid var(--color-stroke);
z-index: 100;
}
.header__inner {
display: flex;
align-items: center;
height: 100%;
gap: 40px;
}
.header__logo-img {
height: 40px;
}
.header__nav {
display: flex;
align-items: center;
gap: 24px;
}
.header__link {
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-text-black);
transition: color var(--transition-fast);
}
.header__link:hover {
color: var(--color-primary);
}
.header__link.router-link-active {
color: var(--color-primary);
}
.header__actions {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
}
.header__icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
color: var(--color-text-gray);
transition: color var(--transition-fast);
}
.header__icon-btn:hover {
color: var(--color-primary);
}
.header__notif .badge {
position: absolute;
top: 4px;
right: 4px;
}
.header__profile {
cursor: pointer;
}
.header__avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
background: var(--color-light-green);
}
.header__burger {
display: none;
flex-direction: column;
gap: 5px;
padding: 8px;
margin-left: auto;
}
.header__burger span {
display: block;
width: 24px;
height: 2px;
background: var(--color-text-black);
border-radius: 2px;
transition: all var(--transition-fast);
}
@media (max-width: 744px) {
.header__nav {
display: none;
}
.header__actions .btn {
display: none;
}
.header__actions .header__icon-btn,
.header__actions .header__profile {
display: none;
}
.header__burger {
display: flex;
}
}
</style>
@@ -0,0 +1,89 @@
import type { ApiError } from '~/types'
export const useApi = () => {
const config = useRuntimeConfig()
const authStore = useAuthStore()
const baseURL = config.public.apiBase as string
const request = async <T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> => {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
if (authStore.token) {
headers['Authorization'] = `Bearer ${authStore.token}`
}
const response = await fetch(`${baseURL}${endpoint}`, {
...options,
headers,
credentials: 'include',
})
if (response.status === 401) {
try {
const refreshed = await authStore.refreshToken()
if (refreshed) {
headers['Authorization'] = `Bearer ${authStore.token}`
const retryResponse = await fetch(`${baseURL}${endpoint}`, {
...options,
headers,
credentials: 'include',
})
if (!retryResponse.ok) {
const retryError = await retryResponse.json().catch(() => ({}))
throw {
message: retryError.message || 'Request failed',
status: retryResponse.status,
} as ApiError
}
return retryResponse.json()
}
} catch {
authStore.logout()
navigateTo('/auth/login')
throw { message: 'Session expired', status: 401 } as ApiError
}
}
if (!response.ok) {
const error: ApiError = await response.json().catch(() => ({
message: 'Network error',
}))
error.status = response.status
throw error
}
if (response.status === 204) {
return {} as T
}
return response.json()
}
return {
get: <T>(endpoint: string) => request<T>(endpoint),
post: <T>(endpoint: string, body?: unknown) =>
request<T>(endpoint, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(endpoint: string, body?: unknown) =>
request<T>(endpoint, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
upload: <T>(endpoint: string, formData: FormData) => {
const headers: HeadersInit = {}
if (authStore.token) {
headers['Authorization'] = `Bearer ${authStore.token}`
}
return request<T>(endpoint, {
method: 'POST',
body: formData,
headers,
})
},
}
}
@@ -0,0 +1,40 @@
import type { User } from '~/types'
export const useAuth = () => {
const authStore = useAuthStore()
const isAuthenticated = computed(() => authStore.isAuthenticated)
const user = computed<User | null>(() => authStore.user)
const loading = computed(() => authStore.loading)
const login = async (email: string, password: string) => {
await authStore.login(email, password)
}
const register = async (data: {
name: string
email: string
password: string
password_confirm: string
phone?: string
}) => {
await authStore.register(data)
}
const logout = () => {
authStore.logout()
navigateTo('/')
}
const fetchUser = () => authStore.fetchUser()
return {
isAuthenticated,
user,
loading,
login,
register,
logout,
fetchUser,
}
}
@@ -0,0 +1,48 @@
<template>
<div class="error-page">
<div class="error-page__content">
<h1 class="error-page__code">{{ error?.statusCode || 404 }}</h1>
<p class="body-text--gray error-page__message">
{{ error?.message || 'Страница не найдена' }}
</p>
<NuxtLink to="/" class="btn btn--primary btn--md">
На главную
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
error?: {
statusCode?: number
message?: string
}
}>()
</script>
<style scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.error-page__content {
text-align: center;
}
.error-page__code {
font-family: var(--font-display);
font-size: 120px;
color: var(--color-primary);
line-height: 1;
margin-bottom: 16px;
}
.error-page__message {
margin-bottom: 32px;
}
</style>
@@ -0,0 +1,47 @@
<template>
<div class="auth-layout">
<div class="auth-layout__container">
<div class="auth-layout__header">
<NuxtLink to="/" class="auth-layout__logo">
<img src="/logo.svg" alt="Ял Арба" />
</NuxtLink>
</div>
<main class="auth-layout__content">
<slot />
</main>
</div>
</div>
</template>
<style scoped>
.auth-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-gray);
padding: 16px;
}
.auth-layout__container {
width: 100%;
max-width: 440px;
}
.auth-layout__header {
text-align: center;
margin-bottom: 32px;
}
.auth-layout__logo img {
height: 48px;
margin: 0 auto;
}
.auth-layout__content {
background: var(--color-white);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-md);
}
</style>
@@ -0,0 +1,35 @@
<template>
<div class="layout">
<YalHeader />
<main class="main-content">
<slot />
</main>
<YalFooter />
<BottomNav />
</div>
</template>
<script setup lang="ts">
import YalHeader from '~/components/layout/YalHeader.vue'
import YalFooter from '~/components/layout/YalFooter.vue'
import BottomNav from '~/components/layout/BottomNav.vue'
</script>
<style scoped>
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding-top: var(--header-height);
}
@media (max-width: 744px) {
.main-content {
padding-bottom: var(--bottom-nav-height);
}
}
</style>
@@ -0,0 +1,13 @@
<template>
<div class="empty-layout">
<main>
<slot />
</main>
</div>
</template>
<style scoped>
.empty-layout {
min-height: 100vh;
}
</style>
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
return navigateTo('/auth/login')
}
})
@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
return navigateTo('/')
}
})
@@ -0,0 +1,58 @@
<template>
<div class="about">
<section class="page-hero">
<div class="container">
<h1>О нас</h1>
<p class="body-text--white">Туристическая платформа Республики Башкортостан</p>
</div>
</section>
<section class="section">
<div class="container">
<div class="about__content">
<h2>Ял Арба</h2>
<p class="body-text">
Ял Арба это туристическая платформа, созданная для популяризации туристического потенциала Республики Башкортостан.
Мы объединяем достопримечательности, маршруты, места отдыха и туристические объекты в одном месте.
</p>
<p class="body-text">
Наша миссия сделать путешествия по Башкортостану удобными, доступными и интересными для каждого.
</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'О нас',
})
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
margin-bottom: 12px;
}
.about__content {
max-width: 720px;
margin: 0 auto;
}
.about__content h2 {
margin-bottom: 24px;
}
.about__content p {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,78 @@
<template>
<div class="admin">
<section class="page-hero">
<div class="container">
<h1>Администрирование</h1>
</div>
</section>
<section class="section">
<div class="container">
<div class="admin__grid">
<NuxtLink to="/admin/objects" class="admin__card">
<h4>Объекты</h4>
<p class="small-text">Управление туристическими объектами</p>
</NuxtLink>
<NuxtLink to="/admin/users" class="admin__card">
<h4>Пользователи</h4>
<p class="small-text">Управление пользователями</p>
</NuxtLink>
<NuxtLink to="/admin/reviews" class="admin__card">
<h4>Отзывы</h4>
<p class="small-text">Модерация отзывов</p>
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Администрирование',
middleware: 'auth',
})
</script>
<style scoped>
.page-hero {
background: var(--color-dark);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.admin__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.admin__card {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
padding: 32px 24px;
text-align: center;
transition: all var(--transition-fast);
}
.admin__card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.admin__card h4 {
margin-bottom: 8px;
}
@media (max-width: 744px) {
.admin__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<div class="appeals">
<section class="page-hero">
<div class="container">
<h1>Обращения</h1>
</div>
</section>
<section class="section">
<div class="container">
<div v-if="!isAuthenticated" class="appeals__guest">
<p class="body-text--gray">Войдите, чтобы создать обращение</p>
<NuxtLink to="/auth/login" class="btn btn--primary btn--md">
Войти
</NuxtLink>
</div>
<form v-else @submit.prevent="handleSubmit" class="appeals__form">
<div class="form-group">
<label class="form-label">Тема обращения</label>
<select v-model="form.subject" class="form-select" required>
<option value="">Выберите тему</option>
<option value="bug">Ошибка на сайте</option>
<option value="suggestion">Предложение</option>
<option value="question">Вопрос</option>
<option value="other">Другое</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Сообщение</label>
<textarea
v-model="form.message"
class="form-input"
rows="6"
placeholder="Опишите ваше обращение"
required
></textarea>
</div>
<button type="submit" class="btn btn--primary btn--md" :disabled="sending">
{{ sending ? 'Отправка...' : 'Отправить' }}
</button>
<p v-if="success" class="appeals__success">
Обращение отправлено
</p>
<p v-if="error" class="appeals__error">{{ error }}</p>
</form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Обращения',
})
const { isAuthenticated } = useAuth()
const form = reactive({
subject: '',
message: '',
})
const sending = ref(false)
const success = ref(false)
const error = ref('')
async function handleSubmit() {
sending.value = true
success.value = false
error.value = ''
try {
const api = useApi()
await api.post('/appeals', {
subject: form.subject,
message: form.message,
})
success.value = true
form.subject = ''
form.message = ''
} catch (e: any) {
error.value = e.message || 'Ошибка отправки'
} finally {
sending.value = false
}
}
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.appeals__guest {
text-align: center;
padding: 60px 0;
}
.appeals__form {
max-width: 560px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
textarea.form-input {
resize: vertical;
min-height: 120px;
}
.appeals__success {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-green-accent);
padding: 8px;
background: var(--color-bg-success);
border-radius: var(--radius-sm);
}
.appeals__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
</style>
@@ -0,0 +1,161 @@
<template>
<div class="login">
<h2 class="login__title">Вход</h2>
<p class="small-text login__subtitle">Войдите в свой аккаунт</p>
<form @submit.prevent="handleLogin" class="login__form">
<div class="form-group">
<label class="form-label">Email</label>
<input
v-model="email"
type="email"
class="form-input"
:class="{ 'form-input--error': errors.email }"
placeholder="example@mail.com"
required
/>
<span v-if="errors.email" class="form-error">{{ errors.email }}</span>
</div>
<div class="form-group">
<label class="form-label">Пароль</label>
<input
v-model="password"
type="password"
class="form-input"
:class="{ 'form-input--error': errors.password }"
placeholder="Ваш пароль"
required
/>
<span v-if="errors.password" class="form-error">{{ errors.password }}</span>
</div>
<div class="login__actions">
<NuxtLink to="/auth/reset-password" class="login__forgot">
Забыли пароль?
</NuxtLink>
</div>
<button
type="submit"
class="btn btn--primary btn--lg btn--full"
:disabled="loading"
>
{{ loading ? 'Вход...' : 'Войти' }}
</button>
<p v-if="apiError" class="login__error">{{ apiError }}</p>
</form>
<p class="login__register">
Нет аккаунта?
<NuxtLink to="/auth/register">Зарегистрироваться</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'auth',
title: 'Вход',
middleware: 'guest',
})
const email = ref('')
const password = ref('')
const errors = reactive({ email: '', password: '' })
const apiError = ref('')
const loading = ref(false)
const { login } = useAuth()
async function handleLogin() {
errors.email = ''
errors.password = ''
apiError.value = ''
if (!email.value) {
errors.email = 'Введите email'
return
}
if (!password.value) {
errors.password = 'Введите пароль'
return
}
loading.value = true
try {
await login(email.value, password.value)
navigateTo('/')
} catch (e: any) {
apiError.value = e.message || 'Ошибка входа'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login__title {
text-align: center;
margin-bottom: 8px;
}
.login__subtitle {
text-align: center;
margin-bottom: 24px;
}
.login__form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.login__actions {
display: flex;
justify-content: flex-end;
}
.login__forgot {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-primary);
}
.login__forgot:hover {
text-decoration: underline;
}
.login__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
text-align: center;
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
.login__register {
text-align: center;
margin-top: 24px;
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-text-gray);
}
.login__register a {
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
.login__register a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,194 @@
<template>
<div class="register">
<h2 class="register__title">Регистрация</h2>
<p class="small-text register__subtitle">Создайте аккаунт</p>
<form @submit.prevent="handleRegister" class="register__form">
<div class="form-group">
<label class="form-label">Имя</label>
<input
v-model="name"
type="text"
class="form-input"
:class="{ 'form-input--error': errors.name }"
placeholder="Ваше имя"
required
/>
<span v-if="errors.name" class="form-error">{{ errors.name }}</span>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input
v-model="email"
type="email"
class="form-input"
:class="{ 'form-input--error': errors.email }"
placeholder="example@mail.com"
required
/>
<span v-if="errors.email" class="form-error">{{ errors.email }}</span>
</div>
<div class="form-group">
<label class="form-label">Телефон</label>
<input
v-model="phone"
type="tel"
class="form-input"
placeholder="+7 (XXX) XXX-XX-XX"
/>
</div>
<div class="form-group">
<label class="form-label">Пароль</label>
<input
v-model="password"
type="password"
class="form-input"
:class="{ 'form-input--error': errors.password }"
placeholder="Минимум 6 символов"
required
/>
<span v-if="errors.password" class="form-error">{{ errors.password }}</span>
</div>
<div class="form-group">
<label class="form-label">Подтвердите пароль</label>
<input
v-model="passwordConfirm"
type="password"
class="form-input"
:class="{ 'form-input--error': errors.passwordConfirm }"
placeholder="Повторите пароль"
required
/>
<span v-if="errors.passwordConfirm" class="form-error">{{ errors.passwordConfirm }}</span>
</div>
<button
type="submit"
class="btn btn--primary btn--lg btn--full"
:disabled="loading"
>
{{ loading ? 'Регистрация...' : 'Зарегистрироваться' }}
</button>
<p v-if="apiError" class="register__error">{{ apiError }}</p>
</form>
<p class="register__login">
Уже есть аккаунт?
<NuxtLink to="/auth/login">Войти</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'auth',
title: 'Регистрация',
middleware: 'guest',
})
const name = ref('')
const email = ref('')
const phone = ref('')
const password = ref('')
const passwordConfirm = ref('')
const errors = reactive({
name: '',
email: '',
password: '',
passwordConfirm: '',
})
const apiError = ref('')
const loading = ref(false)
const { register } = useAuth()
async function handleRegister() {
errors.name = ''
errors.email = ''
errors.password = ''
errors.passwordConfirm = ''
apiError.value = ''
if (!name.value) { errors.name = 'Введите имя'; return }
if (!email.value) { errors.email = 'Введите email'; return }
if (!password.value || password.value.length < 6) {
errors.password = 'Пароль минимум 6 символов'
return
}
if (password.value !== passwordConfirm.value) {
errors.passwordConfirm = 'Пароли не совпадают'
return
}
loading.value = true
try {
await register({
name: name.value,
email: email.value,
password: password.value,
password_confirm: passwordConfirm.value,
phone: phone.value || undefined,
})
navigateTo('/')
} catch (e: any) {
apiError.value = e.message || 'Ошибка регистрации'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register__title {
text-align: center;
margin-bottom: 8px;
}
.register__subtitle {
text-align: center;
margin-bottom: 24px;
}
.register__form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.register__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
text-align: center;
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
.register__login {
text-align: center;
margin-top: 24px;
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-text-gray);
}
.register__login a {
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
.register__login a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,132 @@
<template>
<div class="reset">
<h2 class="reset__title">Сброс пароля</h2>
<p class="small-text reset__subtitle">Введите email для сброса пароля</p>
<form @submit.prevent="handleReset" class="reset__form">
<div class="form-group">
<label class="form-label">Email</label>
<input
v-model="email"
type="email"
class="form-input"
:class="{ 'form-input--error': errors.email }"
placeholder="example@mail.com"
required
/>
<span v-if="errors.email" class="form-error">{{ errors.email }}</span>
</div>
<button
type="submit"
class="btn btn--primary btn--lg btn--full"
:disabled="loading"
>
{{ loading ? 'Отправка...' : 'Отправить' }}
</button>
<p v-if="success" class="reset__success">
Инструкция по сбросу пароля отправлена на ваш email
</p>
<p v-if="apiError" class="reset__error">{{ apiError }}</p>
</form>
<p class="reset__back">
<NuxtLink to="/auth/login">Вернуться ко входу</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'auth',
title: 'Сброс пароля',
middleware: 'guest',
})
const email = ref('')
const errors = reactive({ email: '' })
const apiError = ref('')
const success = ref(false)
const loading = ref(false)
async function handleReset() {
errors.email = ''
apiError.value = ''
success.value = false
if (!email.value) {
errors.email = 'Введите email'
return
}
loading.value = true
try {
const api = useApi()
await api.post('/auth/reset-password', { email: email.value })
success.value = true
} catch (e: any) {
apiError.value = e.message || 'Ошибка отправки'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.reset__title {
text-align: center;
margin-bottom: 8px;
}
.reset__subtitle {
text-align: center;
margin-bottom: 24px;
}
.reset__form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.reset__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
text-align: center;
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
.reset__success {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-green-accent);
text-align: center;
padding: 8px;
background: var(--color-bg-success);
border-radius: var(--radius-sm);
}
.reset__back {
text-align: center;
margin-top: 24px;
}
.reset__back a {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-primary);
}
.reset__back a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,83 @@
<template>
<div class="contacts">
<section class="page-hero">
<div class="container">
<h1>Контакты</h1>
</div>
</section>
<section class="section">
<div class="container">
<div class="contacts__grid">
<div class="contacts__card">
<h4>Email</h4>
<a href="mailto:info@yalarba.ru" class="contacts__link">info@yalarba.ru</a>
</div>
<div class="contacts__card">
<h4>Телефон</h4>
<a href="tel:+73472999999" class="contacts__link">+7 (347) 299-99-99</a>
</div>
<div class="contacts__card">
<h4>Адрес</h4>
<p class="body-text--gray">г. Уфа, Республика Башкортостан</p>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Контакты',
})
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.contacts__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 800px;
margin: 0 auto;
}
.contacts__card {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
padding: 24px;
text-align: center;
}
.contacts__card h4 {
margin-bottom: 8px;
}
.contacts__link {
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-primary);
}
.contacts__link:hover {
text-decoration: underline;
}
@media (max-width: 744px) {
.contacts__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,58 @@
<template>
<div class="favorites">
<section class="page-hero">
<div class="container">
<h1>Избранное</h1>
</div>
</section>
<section class="section">
<div class="container">
<div v-if="!isAuthenticated" class="favorites__guest">
<p class="body-text--gray">Войдите, чтобы увидеть избранное</p>
<NuxtLink to="/auth/login" class="btn btn--primary btn--md">
Войти
</NuxtLink>
</div>
<div v-else class="favorites__empty">
<p class="body-text--gray">У вас пока нет избранных мест</p>
<NuxtLink to="/search" class="btn btn--primary btn--md">
Найти места
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Избранное',
})
const { isAuthenticated } = useAuth()
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.favorites__guest,
.favorites__empty {
text-align: center;
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
</style>
@@ -0,0 +1,355 @@
<template>
<div class="home">
<section class="hero">
<div class="container">
<div class="hero__content">
<h1 class="hero__title">Ял Арба</h1>
<p class="hero__subtitle">
Туристическая платформа Республики Башкортостан
</p>
<div class="hero__search">
<div class="hero__search-input">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input
v-model="searchQuery"
type="text"
placeholder="Поиск достопримечательностей, маршрутов, мест..."
@keyup.enter="doSearch"
/>
</div>
<button class="btn btn--primary btn--lg" @click="doSearch">
Найти
</button>
</div>
</div>
</div>
</section>
<section class="section categories">
<div class="container">
<h2 class="categories__title">Популярные категории</h2>
<div class="categories__grid">
<NuxtLink
v-for="cat in categories"
:key="cat.id"
:to="`/search?category_id=${cat.id}`"
class="categories__card"
>
<div class="categories__icon">
<component :is="cat.icon" />
</div>
<span class="categories__name">{{ cat.name }}</span>
</NuxtLink>
</div>
</div>
</section>
<section class="section popular">
<div class="container">
<div class="popular__header">
<h2>Популярные места</h2>
<NuxtLink to="/search" class="btn btn--secondary btn--sm">
Все места
</NuxtLink>
</div>
<div v-if="loading" class="popular__grid">
<div v-for="n in 4" :key="n" class="card">
<div class="skeleton" style="height: 200px" />
<div style="padding: 16px">
<div class="skeleton" style="height: 20px; width: 80%; margin-bottom: 8px" />
<div class="skeleton" style="height: 16px; width: 60%" />
</div>
</div>
</div>
<div v-else-if="objects.length" class="popular__grid">
<NuxtLink
v-for="obj in objects"
:key="obj.id"
:to="`/objects/${obj.id}`"
class="card object-card"
>
<div class="object-card__image">
<img
v-if="obj.images?.[0]"
:src="obj.images[0]"
:alt="obj.title"
/>
<div v-else class="object-card__placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="2"/>
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
<div class="object-card__info">
<h5 class="object-card__title">{{ obj.title }}</h5>
<p v-if="obj.short_description" class="small-text object-card__desc">
{{ obj.short_description }}
</p>
<div class="object-card__meta">
<span v-if="obj.rating" class="object-card__rating">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#FF8833">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{{ obj.rating.toFixed(1) }}
</span>
<span v-if="obj.category_name" class="tag">{{ obj.category_name }}</span>
</div>
</div>
</NuxtLink>
</div>
<div v-else class="popular__empty">
<p class="body-text--gray">Пока нет добавленных мест</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { CATEGORIES } from '~/utils/constants'
const searchQuery = ref('')
const objectsStore = useObjectsStore()
const { objects, loading } = storeToRefs(objectsStore)
const categories = CATEGORIES
onMounted(() => {
objectsStore.fetchObjects({ per_page: 4, sort_by: 'rating', sort_order: 'desc' })
})
function doSearch() {
if (searchQuery.value.trim()) {
navigateTo(`/search?query=${encodeURIComponent(searchQuery.value.trim())}`)
}
}
definePageMeta({
title: 'Ял Арба',
})
</script>
<style scoped>
.hero {
background: linear-gradient(135deg, var(--color-primary) 0%, #0d4a25 100%);
color: var(--color-text-white);
padding: 100px 0 80px;
text-align: center;
}
.hero__title {
font-family: var(--font-display);
font-size: var(--font-size-h1);
color: var(--color-text-white);
margin-bottom: 16px;
}
.hero__subtitle {
font-family: var(--font-body);
font-size: 20px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 40px;
}
.hero__search {
display: flex;
gap: 12px;
max-width: 640px;
margin: 0 auto;
}
.hero__search-input {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
background: var(--color-white);
border-radius: var(--radius-sm);
padding: 0 16px;
}
.hero__search-input svg {
color: var(--color-text-gray);
flex-shrink: 0;
}
.hero__search-input input {
flex: 1;
height: 56px;
border: none;
outline: none;
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-text-black);
background: transparent;
}
.hero__search-input input::placeholder {
color: var(--color-text-gray);
}
.categories__title {
margin-bottom: 32px;
}
.categories__grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
}
.categories__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px 16px;
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
text-align: center;
transition: all var(--transition-fast);
}
.categories__card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.categories__icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-light-green);
border-radius: var(--radius-full);
color: var(--color-primary);
}
.categories__name {
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-text-black);
}
.popular__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.popular__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.object-card {
cursor: pointer;
}
.object-card__image {
height: 200px;
overflow: hidden;
}
.object-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.object-card__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-gray);
color: var(--color-text-gray);
}
.object-card__info {
padding: 16px;
}
.object-card__title {
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.object-card__desc {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.object-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.object-card__rating {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-text-black);
}
.popular__empty {
text-align: center;
padding: 60px 0;
}
@media (max-width: 1024px) {
.popular__grid {
grid-template-columns: repeat(2, 1fr);
}
.categories__grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 744px) {
.hero {
padding: 60px 0 48px;
}
.hero__search {
flex-direction: column;
}
.hero__search-input input {
height: 48px;
}
.hero__search .btn {
width: 100%;
}
.categories__grid {
grid-template-columns: repeat(2, 1fr);
}
.popular__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,41 @@
<template>
<div class="notifications">
<section class="page-hero">
<div class="container">
<h1>Уведомления</h1>
</div>
</section>
<section class="section">
<div class="container">
<div class="notifications__empty">
<p class="body-text--gray">У вас пока нет уведомлений</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Уведомления',
})
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.notifications__empty {
text-align: center;
padding: 60px 0;
}
</style>
@@ -0,0 +1,210 @@
<template>
<div class="object-detail">
<div v-if="loading" class="container section">
<div class="skeleton" style="height: 400px; margin-bottom: 24px" />
<div class="skeleton" style="height: 32px; width: 60%; margin-bottom: 16px" />
<div class="skeleton" style="height: 16px; width: 40%; margin-bottom: 8px" />
<div class="skeleton" style="height: 100px" />
</div>
<template v-else-if="object">
<section class="object-gallery">
<div class="container">
<div class="object-gallery__main">
<img
v-if="object.images?.[0]"
:src="object.images[0]"
:alt="object.title"
/>
<div v-else class="object-gallery__placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="2"/>
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="object-layout">
<div class="object-content">
<div class="object-header">
<h1>{{ object.title }}</h1>
<div class="object-header__meta">
<span v-if="object.category_name" class="tag">{{ object.category_name }}</span>
<span v-if="object.rating" class="object-rating">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#FF8833">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{{ object.rating.toFixed(1) }}
<span v-if="object.review_count">({{ object.review_count }})</span>
</span>
</div>
</div>
<div v-if="object.location" class="object-location">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"/>
</svg>
{{ object.location }}
</div>
<div class="object-description">
<h3>Описание</h3>
<p class="body-text">{{ object.description }}</p>
</div>
<div class="object-reviews">
<h3>Отзывы</h3>
<p class="small-text">Здесь будут отзывы о месте</p>
</div>
</div>
<aside class="object-sidebar">
<div class="object-actions">
<button class="btn btn--secondary btn--full btn--md">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
В избранное
</button>
</div>
</aside>
</div>
</div>
</section>
</template>
<div v-else-if="error" class="container section">
<p class="body-text--gray">Объект не найден</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Объект',
})
const route = useRoute()
const objectsStore = useObjectsStore()
const { currentObject: object, loading, error } = storeToRefs(objectsStore)
const id = computed(() => Number(route.params.id))
onMounted(() => {
if (id.value) {
objectsStore.fetchObject(id.value)
}
})
watch(id, (newId) => {
if (newId) {
objectsStore.fetchObject(newId)
}
})
</script>
<style scoped>
.object-gallery {
background: var(--color-bg-gray);
}
.object-gallery__main {
max-height: 400px;
overflow: hidden;
}
.object-gallery__main img {
width: 100%;
height: 400px;
object-fit: cover;
}
.object-gallery__placeholder {
height: 400px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-gray);
}
.object-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 40px;
align-items: start;
}
.object-header {
margin-bottom: 16px;
}
.object-header h1 {
margin-bottom: 12px;
}
.object-header__meta {
display: flex;
align-items: center;
gap: 12px;
}
.object-rating {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
font-size: var(--font-size-body);
font-weight: var(--font-weight-semibold);
color: var(--color-text-black);
}
.object-location {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-text-gray);
margin-bottom: 24px;
}
.object-description {
margin-bottom: 40px;
}
.object-description h3 {
margin-bottom: 16px;
}
.object-reviews h3 {
margin-bottom: 16px;
}
.object-sidebar {
position: sticky;
top: calc(var(--header-height) + 24px);
}
.object-actions {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
padding: 20px;
}
@media (max-width: 744px) {
.object-layout {
grid-template-columns: 1fr;
}
.object-sidebar {
position: static;
}
}
</style>
@@ -0,0 +1,131 @@
<template>
<div class="create-object">
<section class="page-hero">
<div class="container">
<h1>Добавить объект</h1>
</div>
</section>
<section class="section">
<div class="container">
<form @submit.prevent="handleCreate" class="create-object__form">
<div class="form-group">
<label class="form-label">Название</label>
<input
v-model="form.title"
type="text"
class="form-input"
required
/>
</div>
<div class="form-group">
<label class="form-label">Краткое описание</label>
<input
v-model="form.short_description"
type="text"
class="form-input"
/>
</div>
<div class="form-group">
<label class="form-label">Описание</label>
<textarea
v-model="form.description"
class="form-input"
rows="6"
required
></textarea>
</div>
<div class="form-group">
<label class="form-label">Местоположение</label>
<input
v-model="form.location"
type="text"
class="form-input"
placeholder="Город, адрес"
/>
</div>
<button type="submit" class="btn btn--primary btn--lg" :disabled="saving">
{{ saving ? 'Сохранение...' : 'Создать объект' }}
</button>
<p v-if="error" class="create-object__error">{{ error }}</p>
</form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Добавить объект',
middleware: 'auth',
})
const objectsStore = useObjectsStore()
const form = reactive({
title: '',
short_description: '',
description: '',
location: '',
})
const saving = ref(false)
const error = ref('')
async function handleCreate() {
saving.value = true
error.value = ''
try {
const obj = await objectsStore.createObject({ ...form })
navigateTo(`/objects/${obj.id}`)
} catch (e: any) {
error.value = e.message || 'Ошибка создания'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.create-object__form {
max-width: 640px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
textarea.form-input {
resize: vertical;
min-height: 120px;
}
.create-object__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
</style>
@@ -0,0 +1,175 @@
<template>
<div class="edit-object">
<section class="page-hero">
<div class="container">
<h1>Редактировать объект</h1>
</div>
</section>
<section class="section">
<div class="container">
<div v-if="loading" class="edit-object__loading">
<div class="skeleton" style="height: 48px; width: 100%; margin-bottom: 16px" />
<div class="skeleton" style="height: 200px; width: 100%" />
</div>
<form v-else-if="form" @submit.prevent="handleUpdate" class="edit-object__form">
<div class="form-group">
<label class="form-label">Название</label>
<input
v-model="form.title"
type="text"
class="form-input"
required
/>
</div>
<div class="form-group">
<label class="form-label">Краткое описание</label>
<input
v-model="form.short_description"
type="text"
class="form-input"
/>
</div>
<div class="form-group">
<label class="form-label">Описание</label>
<textarea
v-model="form.description"
class="form-input"
rows="6"
required
></textarea>
</div>
<div class="form-group">
<label class="form-label">Местоположение</label>
<input
v-model="form.location"
type="text"
class="form-input"
/>
</div>
<div class="edit-object__actions">
<button type="submit" class="btn btn--primary btn--lg" :disabled="saving">
{{ saving ? 'Сохранение...' : 'Сохранить' }}
</button>
<button type="button" class="btn btn--danger btn--lg" @click="handleDelete">
Удалить
</button>
</div>
<p v-if="error" class="edit-object__error">{{ error }}</p>
</form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Редактировать объект',
middleware: 'auth',
})
const route = useRoute()
const objectsStore = useObjectsStore()
const id = computed(() => Number(route.params.id))
const form = reactive({
title: '',
short_description: '',
description: '',
location: '',
})
const loading = ref(true)
const saving = ref(false)
const error = ref('')
onMounted(async () => {
try {
await objectsStore.fetchObject(id.value)
if (objectsStore.currentObject) {
form.title = objectsStore.currentObject.title
form.short_description = objectsStore.currentObject.short_description || ''
form.description = objectsStore.currentObject.description
form.location = objectsStore.currentObject.location || ''
}
} catch {
error.value = 'Объект не найден'
} finally {
loading.value = false
}
})
async function handleUpdate() {
saving.value = true
error.value = ''
try {
await objectsStore.updateObject(id.value, { ...form })
navigateTo(`/objects/${id.value}`)
} catch (e: any) {
error.value = e.message || 'Ошибка обновления'
} finally {
saving.value = false
}
}
async function handleDelete() {
if (!confirm('Вы уверены, что хотите удалить объект?')) return
error.value = ''
try {
await objectsStore.deleteObject(id.value)
navigateTo('/objects/my')
} catch (e: any) {
error.value = e.message || 'Ошибка удаления'
}
}
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.edit-object__form {
max-width: 640px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
}
textarea.form-input {
resize: vertical;
min-height: 120px;
}
.edit-object__actions {
display: flex;
gap: 12px;
}
.edit-object__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
</style>
@@ -0,0 +1,145 @@
<template>
<div class="my-objects">
<section class="page-hero">
<div class="container">
<h1>Мои объекты</h1>
<NuxtLink to="/objects/create" class="btn btn--primary btn--md">
Добавить объект
</NuxtLink>
</div>
</section>
<section class="section">
<div class="container">
<div v-if="loading" class="my-objects__grid">
<div v-for="n in 3" :key="n" class="card">
<div class="skeleton" style="height: 160px" />
<div style="padding: 16px">
<div class="skeleton" style="height: 20px; width: 80%; margin-bottom: 8px" />
<div class="skeleton" style="height: 16px; width: 60%" />
</div>
</div>
</div>
<div v-else-if="objects.length" class="my-objects__grid">
<NuxtLink
v-for="obj in objects"
:key="obj.id"
:to="`/objects/edit/${obj.id}`"
class="card object-card"
>
<div class="object-card__image">
<img
v-if="obj.images?.[0]"
:src="obj.images[0]"
:alt="obj.title"
/>
<div v-else class="object-card__placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="2"/>
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
<div class="object-card__info">
<h5>{{ obj.title }}</h5>
<p class="small-text">{{ obj.status || 'Активен' }}</p>
</div>
</NuxtLink>
</div>
<div v-else class="my-objects__empty">
<p class="body-text--gray">У вас пока нет объектов</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Мои объекты',
middleware: 'auth',
})
const objectsStore = useObjectsStore()
const { objects, loading } = storeToRefs(objectsStore)
onMounted(async () => {
const { user } = useAuth()
if (user.value?.id) {
try {
const api = useApi()
const response = await api.get<{ data: typeof objects.value }>(`/objects/owner/${user.value.id}`)
objectsStore.objects = response.data
} catch {
// ignore
}
}
})
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
margin-bottom: 16px;
}
.my-objects__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.object-card {
cursor: pointer;
}
.object-card__image {
height: 160px;
overflow: hidden;
}
.object-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.object-card__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-gray);
color: var(--color-text-gray);
}
.object-card__info {
padding: 16px;
}
.object-card__info h5 {
margin-bottom: 4px;
}
.my-objects__empty {
text-align: center;
padding: 60px 0;
}
@media (max-width: 744px) {
.my-objects__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="profile">
<section class="page-hero">
<div class="container">
<h1>Профиль</h1>
</div>
</section>
<section class="section">
<div class="container">
<div v-if="!isAuthenticated" class="profile__guest">
<p class="body-text--gray">Войдите, чтобы увидеть профиль</p>
<NuxtLink to="/auth/login" class="btn btn--primary btn--md">
Войти
</NuxtLink>
</div>
<template v-else-if="user">
<div class="profile__header">
<div class="profile__avatar">
<img v-if="user.avatar" :src="user.avatar" alt="" class="avatar avatar--lg" />
<span v-else class="avatar avatar--lg profile__avatar-placeholder">
{{ userInitials }}
</span>
</div>
<div class="profile__info">
<h2>{{ user.name }}</h2>
<p class="body-text--gray">{{ user.email }}</p>
<span class="tag" style="margin-top: 8px">
{{ user.role === 'admin' ? 'Администратор' : user.role === 'moderator' ? 'Модератор' : 'Пользователь' }}
</span>
</div>
</div>
<div class="profile__nav">
<NuxtLink to="/profile" class="profile__tab profile__tab--active">
Обзор
</NuxtLink>
<NuxtLink to="/objects/my" class="profile__tab">
Мои объекты
</NuxtLink>
<NuxtLink to="/profile/settings" class="profile__tab">
Настройки
</NuxtLink>
<button class="profile__tab profile__tab--logout" @click="handleLogout">
Выйти
</button>
</div>
<div class="profile__content">
<div class="profile__stats">
<div class="profile__stat-card">
<h3>{{ objectsCount }}</h3>
<p class="small-text">Объектов</p>
</div>
<div class="profile__stat-card">
<h3>{{ reviewsCount }}</h3>
<p class="small-text">Отзывов</p>
</div>
<div class="profile__stat-card">
<h3>{{ favoritesCount }}</h3>
<p class="small-text">В избранном</p>
</div>
</div>
</div>
</template>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Профиль',
middleware: 'auth',
})
const { isAuthenticated, user, logout } = useAuth()
const objectsCount = ref(0)
const reviewsCount = ref(0)
const favoritesCount = ref(0)
const userInitials = computed(() => {
if (!user.value?.name) return '?'
return user.value.name.charAt(0).toUpperCase()
})
function handleLogout() {
logout()
}
onMounted(async () => {
try {
const api = useApi()
const profile = await api.get<{
objects_count?: number
reviews_count?: number
favorites_count?: number
}>('/profile')
objectsCount.value = profile.objects_count || 0
reviewsCount.value = profile.reviews_count || 0
favoritesCount.value = profile.favorites_count || 0
} catch {
// ignore
}
})
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.profile__guest {
text-align: center;
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.profile__header {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
}
.profile__avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-body);
font-size: 24px;
font-weight: var(--font-weight-bold);
color: var(--color-primary);
background: var(--color-light-green);
}
.profile__info h2 {
margin-bottom: 4px;
}
.profile__nav {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--color-stroke);
margin-bottom: 32px;
overflow-x: auto;
}
.profile__tab {
padding: 12px 20px;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--color-text-gray);
border-bottom: 2px solid transparent;
transition: all var(--transition-fast);
white-space: nowrap;
background: none;
}
.profile__tab:hover {
color: var(--color-text-black);
}
.profile__tab--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.profile__tab--logout {
margin-left: auto;
color: var(--color-red);
}
.profile__tab--logout:hover {
color: var(--color-red);
opacity: 0.8;
}
.profile__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
max-width: 600px;
}
.profile__stat-card {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
padding: 24px;
text-align: center;
}
.profile__stat-card h3 {
color: var(--color-primary);
margin-bottom: 4px;
}
@media (max-width: 744px) {
.profile__header {
flex-direction: column;
text-align: center;
}
.profile__stats {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,142 @@
<template>
<div class="settings-page">
<section class="page-hero">
<div class="container">
<h1>Настройки профиля</h1>
</div>
</section>
<section class="section">
<div class="container">
<form @submit.prevent="handleUpdate" class="settings-page__form">
<div class="form-group">
<label class="form-label">Имя</label>
<input
v-model="form.name"
type="text"
class="form-input"
required
/>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input
v-model="form.email"
type="email"
class="form-input"
required
/>
</div>
<div class="form-group">
<label class="form-label">Телефон</label>
<input
v-model="form.phone"
type="tel"
class="form-input"
placeholder="+7 (XXX) XXX-XX-XX"
/>
</div>
<button type="submit" class="btn btn--primary btn--md" :disabled="saving">
{{ saving ? 'Сохранение...' : 'Сохранить' }}
</button>
<p v-if="success" class="settings-page__success">Профиль обновлён</p>
<p v-if="error" class="settings-page__error">{{ error }}</p>
</form>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Настройки',
middleware: 'auth',
})
const { user, fetchUser } = useAuth()
const form = reactive({
name: user.value?.name || '',
email: user.value?.email || '',
phone: user.value?.phone || '',
})
const saving = ref(false)
const success = ref(false)
const error = ref('')
watch(user, (u) => {
if (u) {
form.name = u.name
form.email = u.email
form.phone = u.phone || ''
}
})
async function handleUpdate() {
saving.value = true
success.value = false
error.value = ''
try {
const api = useApi()
await api.put('/me', {
name: form.name,
email: form.email,
phone: form.phone || undefined,
})
success.value = true
await fetchUser()
} catch (e: any) {
error.value = e.message || 'Ошибка обновления'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.settings-page__form {
max-width: 440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.settings-page__success {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-green-accent);
padding: 8px;
background: var(--color-bg-success);
border-radius: var(--radius-sm);
}
.settings-page__error {
font-family: var(--font-body);
font-size: var(--font-size-small);
color: var(--color-red);
padding: 8px;
background: var(--color-bg-error);
border-radius: var(--radius-sm);
}
</style>
@@ -0,0 +1,154 @@
<template>
<div class="reviews">
<section class="page-hero">
<div class="container">
<h1>Отзывы</h1>
</div>
</section>
<section class="section">
<div class="container">
<div v-if="loading" class="reviews__list">
<div v-for="n in 3" :key="n" class="card" style="padding: 20px">
<div class="skeleton" style="height: 20px; width: 60%; margin-bottom: 12px" />
<div class="skeleton" style="height: 16px; width: 100%; margin-bottom: 8px" />
<div class="skeleton" style="height: 16px; width: 80%" />
</div>
</div>
<div v-else-if="reviews.length" class="reviews__list">
<div v-for="review in reviews" :key="review.id" class="card review-card">
<div class="review-card__header">
<div class="review-card__user">
<img
v-if="review.user_avatar"
:src="review.user_avatar"
alt=""
class="avatar avatar--sm"
/>
<span v-else class="avatar avatar--sm review-card__avatar-placeholder">
{{ review.user_name?.charAt(0) || '?' }}
</span>
<div>
<p class="body-text--semibold">{{ review.user_name }}</p>
<p class="caption">{{ formatDate(review.created_at) }}</p>
</div>
</div>
<div v-if="review.rating" class="review-card__rating">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#FF8833">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{{ review.rating }}
</div>
</div>
<p class="body-text review-card__text">{{ review.text }}</p>
<p v-if="review.comments_count" class="small-text">
Комментариев: {{ review.comments_count }}
</p>
</div>
</div>
<div v-else class="reviews__empty">
<p class="body-text--gray">Пока нет отзывов</p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { Review } from '~/types'
definePageMeta({
title: 'Отзывы',
})
const reviews = ref<Review[]>([])
const loading = ref(true)
onMounted(async () => {
try {
const api = useApi()
reviews.value = await api.get<Review[]>('/feedbacks')
} catch {
// ignore
} finally {
loading.value = false
}
})
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
}
.reviews__list {
max-width: 720px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.review-card {
padding: 20px;
}
.review-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.review-card__user {
display: flex;
align-items: center;
gap: 12px;
}
.review-card__avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
background: var(--color-light-green);
}
.review-card__rating {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
}
.review-card__text {
margin-bottom: 8px;
}
.reviews__empty {
text-align: center;
padding: 60px 0;
}
</style>
@@ -0,0 +1,379 @@
<template>
<div class="search-page">
<section class="search-hero">
<div class="container">
<div class="search-bar">
<div class="search-bar__input">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20l-3.5-3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input
v-model="searchQuery"
type="text"
placeholder="Поиск достопримечательностей, маршрутов, мест..."
@keyup.enter="doSearch"
/>
</div>
<button class="btn btn--primary btn--md" @click="doSearch">
Найти
</button>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="search-layout">
<aside class="search-filters">
<h5>Фильтры</h5>
<div class="filter-group">
<label class="form-label">Категория</label>
<select v-model="searchStore.filters.category_id" class="form-select" @change="doSearch">
<option :value="null">Все категории</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</div>
<div class="filter-group">
<label class="form-label">Город</label>
<input
v-model="searchStore.filters.city"
type="text"
class="form-input"
placeholder="Город"
@input="doSearch"
/>
</div>
<div class="filter-group">
<label class="form-label">Сортировка</label>
<select v-model="searchStore.filters.sort_by" class="form-select" @change="doSearch">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<button class="btn btn--ghost btn--sm btn--full" @click="resetFilters">
Сбросить фильтры
</button>
</aside>
<div class="search-results">
<div class="search-results__header">
<p class="body-text--gray">
Найдено: <strong>{{ searchStore.total }}</strong>
</p>
</div>
<div v-if="searchStore.loading" class="search-results__grid">
<div v-for="n in 6" :key="n" class="card">
<div class="skeleton" style="height: 180px" />
<div style="padding: 16px">
<div class="skeleton" style="height: 20px; width: 80%; margin-bottom: 8px" />
<div class="skeleton" style="height: 16px; width: 60%" />
</div>
</div>
</div>
<div
v-else-if="searchStore.results.length"
class="search-results__grid"
>
<NuxtLink
v-for="obj in searchStore.results"
:key="obj.id"
:to="`/objects/${obj.id}`"
class="card object-card"
>
<div class="object-card__image">
<img
v-if="obj.images?.[0]"
:src="obj.images[0]"
:alt="obj.title"
/>
<div v-else class="object-card__placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<circle cx="8.5" cy="8.5" r="1.5" stroke="currentColor" stroke-width="2"/>
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
<div class="object-card__info">
<h5 class="object-card__title">{{ obj.title }}</h5>
<p v-if="obj.short_description" class="small-text object-card__desc">
{{ obj.short_description }}
</p>
<div class="object-card__meta">
<span v-if="obj.rating" class="object-card__rating">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#FF8833">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
{{ obj.rating.toFixed(1) }}
</span>
<span v-if="obj.category_name" class="tag">{{ obj.category_name }}</span>
</div>
</div>
</NuxtLink>
</div>
<div v-else-if="hasSearched" class="search-results__empty">
<p class="body-text--gray">Ничего не найдено</p>
<p class="small-text">Попробуйте изменить параметры поиска</p>
</div>
<div v-else class="search-results__empty">
<p class="body-text--gray">Введите запрос для поиска</p>
</div>
<div
v-if="searchStore.totalPages > 1"
class="search-results__pagination"
>
<button
class="btn btn--ghost btn--sm"
:disabled="searchStore.page <= 1"
@click="changePage(searchStore.page - 1)"
>
Назад
</button>
<span class="small-text">
{{ searchStore.page }} / {{ searchStore.totalPages }}
</span>
<button
class="btn btn--ghost btn--sm"
:disabled="searchStore.page >= searchStore.totalPages"
@click="changePage(searchStore.page + 1)"
>
Вперёд
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { CATEGORIES, SORT_OPTIONS } from '~/utils/constants'
definePageMeta({
title: 'Поиск',
})
const route = useRoute()
const searchStore = useSearchStore()
const searchQuery = ref((route.query.query as string) || '')
const hasSearched = ref(false)
const categories = CATEGORIES
const sortOptions = SORT_OPTIONS
onMounted(async () => {
if (route.query.query || route.query.category_id) {
searchQuery.value = (route.query.query as string) || searchStore.query
if (route.query.category_id) {
searchStore.setFilter('category_id', Number(route.query.category_id))
}
hasSearched.value = true
await searchStore.search({ query: searchQuery.value })
}
})
async function doSearch() {
hasSearched.value = true
await searchStore.search({ query: searchQuery.value })
}
function resetFilters() {
searchStore.resetFilters()
doSearch()
}
async function changePage(page: number) {
await searchStore.search({ query: searchQuery.value, page })
}
</script>
<style scoped>
.search-hero {
background: var(--color-primary);
padding: 32px 0;
}
.search-bar {
display: flex;
gap: 12px;
}
.search-bar__input {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
background: var(--color-white);
border-radius: var(--radius-sm);
padding: 0 16px;
}
.search-bar__input svg {
color: var(--color-text-gray);
flex-shrink: 0;
}
.search-bar__input input {
flex: 1;
height: 48px;
border: none;
outline: none;
font-family: var(--font-body);
font-size: var(--font-size-body);
color: var(--color-text-black);
background: transparent;
}
.search-bar__input input::placeholder {
color: var(--color-text-gray);
}
.search-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 32px;
align-items: start;
}
.search-filters {
background: var(--color-white);
border: 1px solid var(--color-stroke);
border-radius: var(--radius-md);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: calc(var(--header-height) + 24px);
}
.filter-group {
display: flex;
flex-direction: column;
}
.search-results__header {
margin-bottom: 16px;
}
.search-results__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.object-card {
cursor: pointer;
}
.object-card__image {
height: 180px;
overflow: hidden;
}
.object-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.object-card__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-gray);
color: var(--color-text-gray);
}
.object-card__info {
padding: 12px;
}
.object-card__title {
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.object-card__desc {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.object-card__meta {
display: flex;
align-items: center;
gap: 8px;
}
.object-card__rating {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-text-black);
}
.search-results__empty {
text-align: center;
padding: 60px 0;
}
.search-results__pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 32px;
}
@media (max-width: 1024px) {
.search-results__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 744px) {
.search-layout {
grid-template-columns: 1fr;
}
.search-filters {
position: static;
}
.search-results__grid {
grid-template-columns: 1fr;
}
.search-bar {
flex-direction: column;
}
.search-bar .btn {
width: 100%;
}
}
</style>
@@ -0,0 +1,167 @@
<template>
<div class="support">
<section class="page-hero">
<div class="container">
<h1>Поддержка</h1>
<p class="body-text--white">Мы всегда готовы помочь</p>
</div>
</section>
<section class="section">
<div class="container">
<div class="support__content">
<h2>Часто задаваемые вопросы</h2>
<div class="support__faq">
<div
v-for="(faq, i) in faqs"
:key="i"
class="support__faq-item"
>
<button
class="support__faq-question"
@click="toggleFaq(i)"
>
{{ faq.question }}
<svg
width="20" height="20" viewBox="0 0 24 24" fill="none"
:class="{ 'support__faq-arrow--open': openFaqs.includes(i) }"
class="support__faq-arrow"
>
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div v-if="openFaqs.includes(i)" class="support__faq-answer">
<p class="body-text--gray">{{ faq.answer }}</p>
</div>
</div>
</div>
<div class="support__contact">
<h3>Не нашли ответ?</h3>
<p class="body-text--gray">Напишите нам на info@yalarba.ru</p>
<a href="mailto:info@yalarba.ru" class="btn btn--primary btn--md">
Написать
</a>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
definePageMeta({
title: 'Поддержка',
})
interface FaqItem {
question: string
answer: string
}
const faqs: FaqItem[] = [
{
question: 'Как зарегистрироваться на платформе?',
answer: 'Нажмите "Регистрация" в правом верхнем углу, заполните форму и подтвердите email.',
},
{
question: 'Как найти достопримечательность?',
answer: 'Используйте поиск на главной странице или перейдите в раздел "Поиск".',
},
{
question: 'Как оставить отзыв?',
answer: 'Перейдите на страницу объекта и нажмите "Оставить отзыв".',
},
]
const openFaqs = ref<number[]>([])
function toggleFaq(i: number) {
const idx = openFaqs.value.indexOf(i)
if (idx === -1) {
openFaqs.value.push(i)
} else {
openFaqs.value.splice(idx, 1)
}
}
</script>
<style scoped>
.page-hero {
background: var(--color-primary);
padding: 60px 0;
text-align: center;
color: var(--color-text-white);
}
.page-hero h1 {
color: var(--color-text-white);
margin-bottom: 12px;
}
.support__content {
max-width: 720px;
margin: 0 auto;
}
.support__content h2 {
margin-bottom: 32px;
}
.support__faq {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 48px;
}
.support__faq-item {
border: 1px solid var(--color-stroke);
border-radius: var(--radius-sm);
overflow: hidden;
}
.support__faq-question {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
font-family: var(--font-body);
font-size: var(--font-size-body);
font-weight: var(--font-weight-medium);
color: var(--color-text-black);
text-align: left;
background: none;
cursor: pointer;
}
.support__faq-arrow {
transition: transform var(--transition-fast);
flex-shrink: 0;
color: var(--color-text-gray);
}
.support__faq-arrow--open {
transform: rotate(180deg);
}
.support__faq-answer {
padding: 0 16px 16px;
}
.support__contact {
text-align: center;
padding: 40px;
background: var(--color-bg-gray);
border-radius: var(--radius-md);
}
.support__contact h3 {
margin-bottom: 8px;
}
.support__contact p {
margin-bottom: 24px;
}
</style>
@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import type { User, LoginRequest, RegisterRequest } from '~/types'
interface AuthState {
user: User | null
token: string | null
loading: boolean
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
user: null,
token: null,
loading: false,
}),
getters: {
isAuthenticated: (state) => !!state.token,
},
actions: {
async login(email: string, password: string) {
this.loading = true
try {
const api = useApi()
const response = await api.post<{ access_token: string; user: User }>('/auth/login', { email, password })
this.token = response.access_token
this.user = response.user
await this.fetchUser()
} finally {
this.loading = false
}
},
async register(data: RegisterRequest) {
this.loading = true
try {
const api = useApi()
const response = await api.post<{ access_token: string; user: User }>('/auth/register', data)
this.token = response.access_token
this.user = response.user
} finally {
this.loading = false
}
},
async fetchUser() {
if (!this.token) return
try {
const api = useApi()
this.user = await api.get<User>('/me')
} catch {
this.token = null
this.user = null
}
},
async refreshToken(): Promise<boolean> {
try {
const api = useApi()
const response = await api.post<{ access_token: string }>('/auth/refresh')
this.token = response.access_token
return true
} catch {
this.token = null
this.user = null
return false
}
},
logout() {
this.token = null
this.user = null
},
},
})
@@ -0,0 +1,111 @@
import { defineStore } from 'pinia'
import type { TourObject, TourObjectFilters, PaginatedResponse } from '~/types'
interface ObjectsState {
objects: TourObject[]
currentObject: TourObject | null
total: number
page: number
totalPages: number
loading: boolean
error: string | null
}
export const useObjectsStore = defineStore('objects', {
state: (): ObjectsState => ({
objects: [],
currentObject: null,
total: 0,
page: 1,
totalPages: 0,
loading: false,
error: null,
}),
actions: {
async fetchObjects(filters?: TourObjectFilters) {
this.loading = true
this.error = null
try {
const api = useApi()
const params = new URLSearchParams()
if (filters?.category_id) params.set('category_id', String(filters.category_id))
if (filters?.city) params.set('city', filters.city)
if (filters?.rating_min) params.set('rating_min', String(filters.rating_min))
if (filters?.sort_by) params.set('sort_by', filters.sort_by)
if (filters?.sort_order) params.set('sort_order', filters.sort_order)
if (filters?.page) params.set('page', String(filters.page))
if (filters?.per_page) params.set('per_page', String(filters.per_page))
const query = params.toString()
const response = await api.get<PaginatedResponse<TourObject>>(`/objects${query ? `?${query}` : ''}`)
this.objects = response.data
this.total = response.total
this.page = response.page
this.totalPages = response.total_pages
} catch (e: any) {
this.error = e.message || 'Failed to fetch objects'
} finally {
this.loading = false
}
},
async fetchObject(id: number) {
this.loading = true
this.error = null
try {
const api = useApi()
this.currentObject = await api.get<TourObject>(`/objects/${id}`)
} catch (e: any) {
this.error = e.message || 'Failed to fetch object'
} finally {
this.loading = false
}
},
async createObject(data: Partial<TourObject>) {
this.loading = true
this.error = null
try {
const api = useApi()
const response = await api.post<TourObject>('/objects', data)
return response
} catch (e: any) {
this.error = e.message || 'Failed to create object'
throw e
} finally {
this.loading = false
}
},
async updateObject(id: number, data: Partial<TourObject>) {
this.loading = true
this.error = null
try {
const api = useApi()
const response = await api.put<TourObject>(`/objects/${id}`, data)
return response
} catch (e: any) {
this.error = e.message || 'Failed to update object'
throw e
} finally {
this.loading = false
}
},
async deleteObject(id: number) {
this.loading = true
this.error = null
try {
const api = useApi()
await api.delete(`/objects/${id}`)
this.objects = this.objects.filter((o) => o.id !== id)
} catch (e: any) {
this.error = e.message || 'Failed to delete object'
throw e
} finally {
this.loading = false
}
},
},
})
@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import type { TourObject, SearchParams } from '~/types'
interface SearchState {
query: string
results: TourObject[]
total: number
page: number
totalPages: number
loading: boolean
filters: {
category_id: number | null
city: string
rating_min: number | null
sort_by: string
}
}
export const useSearchStore = defineStore('search', {
state: (): SearchState => ({
query: '',
results: [],
total: 0,
page: 1,
totalPages: 0,
loading: false,
filters: {
category_id: null,
city: '',
rating_min: null,
sort_by: 'rating_desc',
},
}),
actions: {
setQuery(q: string) {
this.query = q
},
setFilter<K extends keyof SearchState['filters']>(key: K, value: SearchState['filters'][K]) {
this.filters[key] = value
},
resetFilters() {
this.filters = {
category_id: null,
city: '',
rating_min: null,
sort_by: 'rating_desc',
}
},
async search(params?: Partial<SearchParams>) {
this.loading = true
try {
const api = useApi()
const searchParams = new URLSearchParams()
const q = params?.query || this.query
if (q) searchParams.set('query', q)
if (params?.category_id || this.filters.category_id) {
searchParams.set('category_id', String(params?.category_id || this.filters.category_id))
}
if (params?.city || this.filters.city) {
searchParams.set('city', params?.city || this.filters.city)
}
if (params?.page) searchParams.set('page', String(params.page))
if (params?.per_page) searchParams.set('per_page', String(params.per_page))
if (params?.latitude) searchParams.set('latitude', String(params.latitude))
if (params?.longitude) searchParams.set('longitude', String(params.longitude))
if (params?.radius) searchParams.set('radius', String(params.radius))
if (this.filters.sort_by) {
const [sort_by, sort_order] = this.filters.sort_by.split('_')
searchParams.set('sort_by', sort_by)
searchParams.set('sort_order', sort_order || 'desc')
}
const query = searchParams.toString()
const endpoint = params?.latitude
? `/objects/nearby?${query}`
: `/objects/search?${query}`
const response = await api.get<{
data: TourObject[]
total: number
page: number
total_pages: number
}>(endpoint)
this.results = response.data
this.total = response.total
this.page = response.page
this.totalPages = response.total_pages
} finally {
this.loading = false
}
},
},
})
@@ -0,0 +1,105 @@
export interface User {
id: number
name: string
email: string
phone?: string
avatar?: string
role: 'user' | 'admin' | 'moderator'
created_at?: string
}
export interface AuthTokens {
access_token: string
refresh_token?: string
}
export interface LoginRequest {
email: string
password: string
}
export interface RegisterRequest {
name: string
email: string
password: string
password_confirm: string
phone?: string
}
export interface ResetPasswordRequest {
email: string
}
export interface ResetPasswordConfirmRequest {
token: string
password: string
password_confirm: string
}
export interface TourObject {
id: number
title: string
description: string
short_description?: string
category_id?: number
category_name?: string
images: string[]
location?: string
latitude?: number
longitude?: number
rating?: number
review_count?: number
owner_id?: number
owner_name?: string
status?: string
created_at?: string
updated_at?: string
}
export interface TourObjectFilters {
category_id?: number
city?: string
rating_min?: number
sort_by?: string
sort_order?: 'asc' | 'desc'
page?: number
per_page?: number
}
export interface SearchParams {
query?: string
category_id?: number
city?: string
latitude?: number
longitude?: number
radius?: number
page?: number
per_page?: number
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
per_page: number
total_pages: number
}
export interface Review {
id: number
object_id: number
user_id: number
user_name: string
user_avatar?: string
rating: number
text: string
images?: string[]
created_at: string
comments_count?: number
}
export interface ApiError {
message: string
errors?: Record<string, string[]>
status?: number
}
@@ -0,0 +1,36 @@
export const API_ROUTES = {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
REFRESH: '/auth/refresh',
LOGOUT: '/auth/logout',
RESET_PASSWORD: '/auth/reset-password',
RESET_PASSWORD_CONFIRM: '/auth/reset-password/confirm',
PROFILE: '/profile',
ME: '/me',
OBJECTS: '/objects',
OBJECTS_SEARCH: '/objects/search',
OBJECTS_NEARBY: '/objects/nearby',
FEEDBACKS: '/feedbacks',
RATINGS: '/ratings',
APPEALS: '/appeals',
} as const
export const CATEGORIES = [
{ id: 1, name: 'Достопримечательности', icon: 'landmark' },
{ id: 2, name: 'Музеи', icon: 'museum' },
{ id: 3, name: 'Парки', icon: 'tree' },
{ id: 4, name: 'Гостиницы', icon: 'hotel' },
{ id: 5, name: 'Рестораны', icon: 'utensils' },
{ id: 6, name: 'Маршруты', icon: 'route' },
] as const
export const SORT_OPTIONS = [
{ value: 'rating_desc', label: 'По рейтингу' },
{ value: 'created_at_desc', label: 'Новые' },
{ value: 'created_at_asc', label: 'Старые' },
{ value: 'title_asc', label: 'По алфавиту А-Я' },
{ value: 'title_desc', label: 'По алфавиту Я-А' },
] as const
export const PER_PAGE_OPTIONS = [12, 24, 48] as const
export const DEFAULT_PER_PAGE = 12
@@ -0,0 +1,7 @@
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'vue/multi-word-component-names': 'off',
},
})
@@ -0,0 +1,91 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
future: {
compatibilityVersion: 4
},
site: {
url: 'https://yalarba.ru',
name: 'YalArba',
description: 'Ял Арба — туристическая платформа Республики Башкортостан',
},
sitemap: {
siteUrl: 'https://yalarba.ru',
gzip: true,
cacheMaxAgeSeconds: 3600,
sources: [
'/api/__sitemap__/urls'
],
exclude: [
'/admin/**',
'/auth/**'
],
defaults: {
changefreq: 'daily',
priority: 0.7,
lastmod: new Date().toISOString()
},
},
features: {
inlineStyles: false
},
modules: [
'@nuxt/image',
'@nuxt/ui',
'@nuxt/eslint',
'@nuxtjs/sitemap',
'@pinia/nuxt'
],
css: [
'~/assets/css/variables.css',
'~/assets/css/fonts.css',
'~/assets/css/typography.css',
'~/assets/css/components.css',
'~/assets/css/main.css'
],
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api/v1',
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'https://yalarba.ru',
}
},
nitro: {
preset: 'node-server',
prerender: {
crawlLinks: false
},
},
vite: {
css: {
devSourcemap: false
}
},
app: {
head: {
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
],
titleTemplate: '%s - Ял Арба',
title: 'Ял Арба',
meta: [
{ name: 'description', content: 'Ял Арба — туристическая платформа Республики Башкортостан. Поиск достопримечательностей, маршрутов и объектов туризма.' },
{ name: 'keywords', content: 'туризм, Башкортостан, достопримечательности, маршруты, отдых, путешествия, Ял Арба' }
]
}
},
ui: {
preset: 'none',
fonts: false
},
})
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "yalarba-nuxt",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@nuxt/eslint": "^1.9.0",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.1.0",
"@nuxtjs/sitemap": "^7.4.7",
"@pinia/nuxt": "^0.11.2",
"nuxt": "^4.2.0",
"pinia": "^3.0.3",
"typescript": "^5.9.3",
"vee-validate": "^4.15.1",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"yup": "^1.7.1"
}
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 48" fill="none">
<rect width="160" height="48" rx="8" fill="#196533"/>
<text x="16" y="32" font-family="Mulish, sans-serif" font-size="20" font-weight="700" fill="white">ЯЛ АРБА</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

@@ -0,0 +1,7 @@
export default defineEventHandler(() => {
return {
status: 'ok',
name: 'yalarba-nuxt',
timestamp: new Date().toISOString(),
}
})
@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}