feat: create Nuxt 4 SPA for yalarba.ru (yalarba-nuxt)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
.git/
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -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"]
|
||||||
@@ -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">© {{ 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
|
||||||
|
},
|
||||||
|
})
|
||||||
+17444
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user