@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-grow">
|
||||
<!-- Список сообщений -->
|
||||
<div class="flex-grow overflow-y-auto p-4" ref="messagesContainer">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message._id"
|
||||
class="mb-4 max-w-md"
|
||||
:class="{ 'ml-auto': message.from === userRole, 'mr-auto': message.from !== userRole }"
|
||||
>
|
||||
<Card
|
||||
class="p-card-sm"
|
||||
:class="{
|
||||
'bg-blue-50 border-blue-300 text-blue-500': message.from === userRole,
|
||||
'bg-gray-50 border-gray-300 text-gray-700': message.from !== userRole,
|
||||
}"
|
||||
>
|
||||
<template #content>
|
||||
{{ message.body }}
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<slot name="endOfMessages" :count="messages.length"></slot>
|
||||
</div>
|
||||
|
||||
<!-- Форма отправки сообщений -->
|
||||
<form @submit.prevent="sendMessage" class="flex gap-2 items-center p-4" ref="form">
|
||||
<InputText
|
||||
id="newMessage"
|
||||
v-model="newMessage"
|
||||
class="flex-1"
|
||||
placeholder="Напишите сообщение"
|
||||
/>
|
||||
<Button type="submit" icon="pi pi-send" class="p-button-md" :disabled="!newMessage.trim()" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, useTemplateRef, watch, nextTick } from 'vue'
|
||||
import { messagesDbLocal as db, messagesDbRemote as remoteDb } from '@/api/dbs'
|
||||
import { type MessageDocument } from '@/types/DBDocumentTypes'
|
||||
|
||||
const formRef = useTemplateRef('form')
|
||||
const messagesContainerRef = useTemplateRef('messagesContainer')
|
||||
|
||||
const { qr_code_uri, chat, userRole } = defineProps<{
|
||||
qr_code_uri: MessageDocument['qr_code_uri']
|
||||
chat: MessageDocument['chat']
|
||||
userRole: MessageDocument['from']
|
||||
}>()
|
||||
|
||||
// Локальные состояния
|
||||
const messages = ref<MessageDocument[]>([])
|
||||
const newMessage = ref('')
|
||||
let changesSubscription: PouchDB.Core.Changes<MessageDocument> | null = null
|
||||
let syncHandler: PouchDB.Replication.Sync<MessageDocument> | null = null
|
||||
|
||||
// Селектор для фильтрации сообщений
|
||||
const messageSelector = {
|
||||
type: 'message',
|
||||
qr_code_uri,
|
||||
chat,
|
||||
}
|
||||
|
||||
// Загрузка сообщений
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const result = await db.find({
|
||||
selector: messageSelector,
|
||||
})
|
||||
messages.value = result.docs as MessageDocument[]
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки сообщений:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Отправка нового сообщения
|
||||
async function sendMessage() {
|
||||
const created_at = new Date().toISOString()
|
||||
const message: MessageDocument = {
|
||||
_id: `message:${qr_code_uri}:${chat}:${created_at}`,
|
||||
type: 'message',
|
||||
qr_code_uri,
|
||||
chat,
|
||||
created_at,
|
||||
from: userRole,
|
||||
body: newMessage.value.trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
await db.put(message)
|
||||
newMessage.value = ''
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки сообщения:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Отслеживание изменений
|
||||
function watchChanges() {
|
||||
changesSubscription = db
|
||||
.changes({
|
||||
since: 'now',
|
||||
live: true,
|
||||
include_docs: true,
|
||||
selector: messageSelector,
|
||||
})
|
||||
.on('change', (change: { doc?: MessageDocument }) => {
|
||||
if (change.doc) {
|
||||
const index = messages.value.findIndex((m) => m._id === change.doc!._id)
|
||||
if (index === -1) {
|
||||
// Если сообщения нет, добавляем его
|
||||
messages.value.push(change.doc)
|
||||
} else {
|
||||
// Если сообщение существует, обновляем его
|
||||
messages.value[index] = change.doc
|
||||
}
|
||||
// Сортируем массив сообщений
|
||||
messages.value.sort((a, b) => (a.created_at > b.created_at ? 1 : -1))
|
||||
}
|
||||
})
|
||||
.on('error', (err) => {
|
||||
console.error('Ошибка отслеживания изменений:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Настройка синхронизации
|
||||
function setupSync() {
|
||||
syncHandler = db.sync(remoteDb, {
|
||||
live: true,
|
||||
retry: true,
|
||||
selector: messageSelector,
|
||||
})
|
||||
syncHandler.on('error', (err) => {
|
||||
console.error('Ошибка синхронизации:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Остановка синхронизации
|
||||
function stopSync() {
|
||||
if (syncHandler) {
|
||||
syncHandler.cancel()
|
||||
syncHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
// Отписка от изменений
|
||||
function stopWatchingChanges() {
|
||||
if (changesSubscription) {
|
||||
changesSubscription.cancel()
|
||||
changesSubscription = null
|
||||
}
|
||||
}
|
||||
|
||||
// Логика жизненного цикла
|
||||
onMounted(async () => {
|
||||
watchChanges()
|
||||
setupSync()
|
||||
await loadMessages()
|
||||
formRef.value?.querySelector('input')?.focus()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => messages.value?.length,
|
||||
async () => {
|
||||
if (messagesContainerRef.value) {
|
||||
await nextTick()
|
||||
messagesContainerRef.value.scrollTop = messagesContainerRef.value.scrollHeight
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWatchingChanges()
|
||||
stopSync()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Чат занимает всё доступное пространство */
|
||||
.flex-grow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
height: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts" generic="R, E">
|
||||
import type { UseQueryReturnType } from '@tanstack/vue-query'
|
||||
|
||||
type Props = {
|
||||
query: UseQueryReturnType<R, E>
|
||||
}
|
||||
|
||||
const { query } = defineProps<Props>()
|
||||
|
||||
const { data, error, refetch, isSuccess, isError, isLoading } = query
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-if="isSuccess" name="default" :data="data! as R" />
|
||||
<slot v-else-if="isLoading" name="loading"><p class="text-gray-500">Загрузка данных...</p></slot>
|
||||
<slot v-else-if="isError" name="error" :error="error as E" :reload="refetch">
|
||||
Ошибка загрузки данных: {{ error && 'message' in error ? error.message : error }}
|
||||
</slot>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<section class="py-8 text-center">
|
||||
<h2 class="text-2xl font-semibold">Примеры использования</h2>
|
||||
<div class="grid gap-6 mt-6 grid-cols-1 md:grid-cols-3 md:gap-8 md:gap-y-0">
|
||||
<!-- Пример 1 -->
|
||||
<div class="grid md:grid-rows-subgrid md:row-span-2">
|
||||
<p class="mb-2 self-center">
|
||||
Автомобиль: получайте уведомления о проблемах с парковкой или фарами.
|
||||
</p>
|
||||
<img
|
||||
src="@/assets/examples/car-200x200.jpg"
|
||||
width="200"
|
||||
height="200"
|
||||
alt="Автомобиль"
|
||||
class="mx-auto rounded-lg row-start-2"
|
||||
/>
|
||||
</div>
|
||||
<!-- Пример 2 -->
|
||||
<div class="grid md:grid-rows-subgrid md:row-span-2">
|
||||
<p class="mb-2 self-center">Питомец: помогите вернуть потерявшегося питомца.</p>
|
||||
<img
|
||||
src="@/assets/examples/dog-200x200.jpg"
|
||||
width="200"
|
||||
height="200"
|
||||
alt="Питомец"
|
||||
class="mx-auto rounded-lg row-start-2"
|
||||
/>
|
||||
</div>
|
||||
<!-- Пример 3 -->
|
||||
<div class="grid md:grid-rows-subgrid md:row-span-2">
|
||||
<p class="mb-2 self-center">Квартира: оставляйте инструкции для курьеров или соседей.</p>
|
||||
<img
|
||||
src="@/assets/examples/door-200x200.jpg"
|
||||
width="200"
|
||||
height="200"
|
||||
alt="Квартира"
|
||||
class="mx-auto rounded-lg row-start-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="max-w-screen-sm mx-auto px-4 md:px-0 font-sans text-gray-800">
|
||||
<!-- Hero Section -->
|
||||
<section class="text-center py-12">
|
||||
<div class="flex flex-col md:flex-row items-center gap-8">
|
||||
<RouterLink to="/">
|
||||
<img
|
||||
src="/images/qr-code-logo-200x200.jpg"
|
||||
width="200"
|
||||
height="200"
|
||||
alt="QR‑код логотип"
|
||||
class="mx-auto"
|
||||
/>
|
||||
</RouterLink>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Создайте уникальный QR‑код для связи</h1>
|
||||
<p class="mt-2 text-lg text-gray-600 text-justify">
|
||||
Люди смогут отправлять вам сообщения, сканируя этот QR‑код, без необходимости раскрывать
|
||||
ваши контактные данные.
|
||||
</p>
|
||||
<Button
|
||||
icon="pi pi-qrcode"
|
||||
label="Создать QR‑код"
|
||||
to="/create"
|
||||
as="router-link"
|
||||
class="p-button-lg mt-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Where to Use Section -->
|
||||
<section id="setup" class="py-8 text-center">
|
||||
<h2 class="text-2xl font-semibold">Где использовать?</h2>
|
||||
<p class="mt-2 text-lg text-gray-600 text-justify">
|
||||
QR‑код можно будет разместить на вашем автомобиле, на входной двери или на ошейнике вашего
|
||||
питомца — это позволит окружающим легко связаться с вами в случае необходимости, при этом
|
||||
ваши контактные данные останутся конфиденциальными.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ExamplesSection id="examples" />
|
||||
|
||||
<!-- Simple Setup Section -->
|
||||
<section id="settings" class="py-8 text-center">
|
||||
<h2 class="text-2xl font-semibold">Легкость настройки</h2>
|
||||
<p class="mt-2 text-lg text-gray-600 text-justify">
|
||||
Создайте QR‑код, выберите доступные действия и начните использовать.
|
||||
</p>
|
||||
<div class="flex justify-center mt-6">
|
||||
<Button
|
||||
icon="pi pi-qrcode"
|
||||
label="Создать QR‑код"
|
||||
to="/create"
|
||||
as="router-link"
|
||||
class="p-button-lg"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 border-t text-center">
|
||||
<p>НаСвязи © 2024</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HelloWorld from '../HelloWorld.vue'
|
||||
|
||||
describe('HelloWorld', () => {
|
||||
it('renders properly', () => {
|
||||
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
||||
expect(wrapper.text()).toContain('Hello Vitest')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Выберите доступные действия</h2>
|
||||
<p class="text-gray-600 mb-4">Укажите, какие действия будут доступны для сканирующих QR‑код.</p>
|
||||
<p class="text-sm text-gray-500 mb-6">Вы сможете изменить эти настройки позже.</p>
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div v-for="(label, action) in actionsOptions" :key="action" class="flex items-center gap-2">
|
||||
<Checkbox v-model="model" :inputId="action" :value="action" @change="next" />
|
||||
<label :for="action">{{ label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['nextStep'])
|
||||
const model = defineModel()
|
||||
|
||||
const actionsOptions = {
|
||||
sendMessage: 'Отправить сообщение',
|
||||
viewContactInfo: 'Посмотреть ваши контактные данные',
|
||||
}
|
||||
|
||||
function next() {
|
||||
emit('nextStep')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Куда отправлять сообщения?</h2>
|
||||
<p class="text-gray-600 mb-4">Выберите, как вы хотите получать сообщения.</p>
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div class="flex items-center gap-2" v-for="option in deliveryOptions" :key="option.method">
|
||||
<RadioButton
|
||||
v-model="model"
|
||||
:inputId="option.method"
|
||||
name="delivery"
|
||||
:value="option.method"
|
||||
@change="handleSelection(option.method)"
|
||||
/>
|
||||
<label :for="option.method">{{ option.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщение о недоступности -->
|
||||
<p v-if="telegramNotice" class="text-red-500 mb-2">
|
||||
Отправка в Telegram пока в разработке.<br />
|
||||
Этот способ отправки сообщений можно будет выбрать позже.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { type QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import { deliveryOptions } from '@/constants/deliveryOptions'
|
||||
|
||||
const emit = defineEmits(['nextStep'])
|
||||
const model = defineModel<QRCodeDocument['messageDeliveryMethod']>()
|
||||
|
||||
// Локальное состояние для уведомления
|
||||
const telegramNotice = ref(false)
|
||||
|
||||
// Обработчик выбора
|
||||
function handleSelection(method: QRCodeDocument['messageDeliveryMethod']) {
|
||||
if (method === 'telegram') {
|
||||
telegramNotice.value = true
|
||||
} else {
|
||||
telegramNotice.value = false
|
||||
emit('nextStep')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Где вы хотите разместить QR‑код?</h2>
|
||||
<p class="text-gray-600 mb-4">Этот выбор поможет настроить QR‑код с подходящими действиями.</p>
|
||||
<div class="flex flex-col gap-3 mb-4">
|
||||
<div v-for="{ label, type } in qrCodePlacements" :key="type" class="flex items-center gap-2">
|
||||
<RadioButton
|
||||
v-model="selectedPlacement"
|
||||
:inputId="type"
|
||||
name="placement"
|
||||
:value="type"
|
||||
@change="$emit('nextStep')"
|
||||
/>
|
||||
<label :for="type">{{ label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { qrCodePlacements } from '@/constants/qrCodePlacements'
|
||||
const selectedPlacement = defineModel<string>()
|
||||
|
||||
defineEmits(['nextStep'])
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Сообщения для отправки</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
После считывания QR‑кода можно будет выбрать одно из этих сообщений или написать своё.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mb-6">Вы сможете изменить эти сообщения позже.</p>
|
||||
|
||||
<!-- Список сообщений -->
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<div v-for="(message, index) in messages" :key="index" class="flex gap-2" ref="messagesRef">
|
||||
<InputText v-model="messages[index]" class="flex-1" placeholder="Введите сообщение" />
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
class="p-button-danger p-button-text"
|
||||
@click="removeMessage(index)"
|
||||
title="Удалить сообщение"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления сообщения -->
|
||||
<Button
|
||||
label="Добавить сообщение"
|
||||
icon="pi pi-plus"
|
||||
class="p-button-sm p-button-outlined mb-4"
|
||||
@click="addMessage"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, useTemplateRef, watch } from 'vue'
|
||||
import { type QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import { defaultMessages } from '@/constants/defaultMessages'
|
||||
|
||||
// Пропсы
|
||||
const props = defineProps<{ placement: QRCodeDocument['placement'] }>()
|
||||
const messagesRef = useTemplateRef<HTMLDivElement[]>('messagesRef')
|
||||
|
||||
// Локальное хранилище сообщений
|
||||
const messages = defineModel<Exclude<QRCodeDocument['predefinedMessages'], undefined>>({
|
||||
required: true,
|
||||
})
|
||||
|
||||
// Следим за типом размещения и устанавливаем предустановленные сообщения при его смене
|
||||
watch(
|
||||
() => props.placement,
|
||||
(newPlacement, previousPlacement) => {
|
||||
let prevDefaultMessages: (typeof defaultMessages)[QRCodeDocument['placement']] = []
|
||||
if (previousPlacement && previousPlacement in defaultMessages) {
|
||||
prevDefaultMessages = defaultMessages[previousPlacement]
|
||||
}
|
||||
const isMessagesDefault =
|
||||
messages.value.length === 0 ||
|
||||
JSON.stringify(messages.value) === JSON.stringify(prevDefaultMessages)
|
||||
if (isMessagesDefault) {
|
||||
messages.value = [...(defaultMessages[newPlacement] || [])]
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Функции для управления сообщениями
|
||||
function addMessage() {
|
||||
messages.value.push('')
|
||||
nextTick(() => {
|
||||
const lastMessageRef = messagesRef.value![messagesRef.value!.length - 1]
|
||||
lastMessageRef?.querySelector('input')?.focus() // Фокусируемся на последнем поле
|
||||
})
|
||||
}
|
||||
|
||||
function removeMessage(index: number) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<section :class="{ 'active-section': isActive }" ref="section">
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, useTemplateRef } from 'vue'
|
||||
|
||||
const props = defineProps<{ isActive: boolean; isNextActiveSection: boolean }>()
|
||||
|
||||
const sectionRef = useTemplateRef('section')
|
||||
|
||||
// плавная прокрутка при активации секции
|
||||
watch(
|
||||
() => sectionRef.value && props.isNextActiveSection,
|
||||
(isNextActiveSection) => {
|
||||
if (isNextActiveSection) {
|
||||
sectionRef.value?.querySelector('input[type="text"]')?.focus()
|
||||
sectionRef.value?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
section {
|
||||
opacity: 0.1;
|
||||
transition: opacity 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
section {
|
||||
border-top: 1px solid silver;
|
||||
padding-top: 1em;
|
||||
}
|
||||
section:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
section.active-section {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Вывод секций через цикл -->
|
||||
<CreateSection
|
||||
v-for="{ name, isActive, isNextActiveSection } in visibleSections"
|
||||
:key="name"
|
||||
:isActive
|
||||
:isNextActiveSection
|
||||
>
|
||||
<slot :name="name" />
|
||||
</CreateSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
|
||||
// Пропсы, которые будут передаваться из родительского компонента
|
||||
const props = defineProps<{
|
||||
placement: null | QRCodeDocument['placement']
|
||||
selectedActions: QRCodeDocument['actions']
|
||||
messageDeliveryMethod: null | QRCodeDocument['messageDeliveryMethod']
|
||||
}>()
|
||||
|
||||
// Доступные шаги и их условия
|
||||
const steps = [
|
||||
{
|
||||
name: 'placement',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
get isActive() {
|
||||
return !!props.placement
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delivery',
|
||||
get isHidden() {
|
||||
return !props.placement || !props.selectedActions.includes('sendMessage')
|
||||
},
|
||||
get isActive() {
|
||||
return props.selectedActions.length > 0 && props.selectedActions.includes('sendMessage')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'messages',
|
||||
get isHidden() {
|
||||
return (
|
||||
!props.placement ||
|
||||
!props.selectedActions.includes('sendMessage') ||
|
||||
props.messageDeliveryMethod === 'telegram'
|
||||
)
|
||||
},
|
||||
get isActive() {
|
||||
return props.messageDeliveryMethod && props.selectedActions.includes('sendMessage')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'summary',
|
||||
get isActive() {
|
||||
return props.selectedActions.includes('sendMessage')
|
||||
? !!props.messageDeliveryMethod && props.messageDeliveryMethod !== 'telegram'
|
||||
: props.selectedActions.length > 0
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const activeSections = computed(() => {
|
||||
const result = []
|
||||
for (const step of steps) {
|
||||
const isActive = !step.isHidden && step.isActive
|
||||
if (isActive) {
|
||||
result.push(step.name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const nextActiveSectionName = ref<string>('')
|
||||
|
||||
watch(
|
||||
activeSections,
|
||||
(newActiveSections, oldActiveSteps) => {
|
||||
nextActiveSectionName.value =
|
||||
(oldActiveSteps && newActiveSections[oldActiveSteps.length]) ?? newActiveSections.at(-1)!
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
// Выводимые секции
|
||||
const visibleSections = computed(() => {
|
||||
const result = []
|
||||
for (const step of steps) {
|
||||
if (step.isHidden) continue
|
||||
result.push({
|
||||
name: step.name,
|
||||
isActive: activeSections.value.includes(step.name),
|
||||
isNextActiveSection: nextActiveSectionName.value === step.name,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-xl font-semibold mb-4">Введите название для вашего QR‑кода</h2>
|
||||
<p class="text-gray-600 mb-4">Название поможет вам организовать ваши QR‑коды.</p>
|
||||
|
||||
<form @submit.prevent="emit('nextStep')">
|
||||
<label for="name" class="block text-gray-700 font-semibold mb-2">Название</label>
|
||||
<InputText id="name" v-model="name" class="w-full" placeholder="Введите название" />
|
||||
<p v-if="!name" class="text-red-500 text-sm mt-1">Название обязательно.</p>
|
||||
|
||||
<Button type="submit" :label="buttonLabel" class="p-button-md mt-4 mb-4" :disabled="!name" />
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
defineProps<{ buttonLabel: string }>()
|
||||
const name = defineModel<QRCodeDocument['name']>('name')
|
||||
const emit = defineEmits(['nextStep'])
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<Button
|
||||
:label
|
||||
icon="pi pi-download"
|
||||
class="p-button-sm p-button-outlined"
|
||||
:disabled="!svgUrl"
|
||||
@click="downloadPng"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { downloadImage } from '@/utils/images/downloadImage'
|
||||
|
||||
const props = defineProps({
|
||||
svgUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filename: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String, // Название кнопки, которое будет отображаться пользователю
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
async function downloadPng() {
|
||||
if (!props.svgUrl) {
|
||||
console.error('SVG URL не предоставлен')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadImage({
|
||||
type: props.type,
|
||||
filename: `${props.filename}.png`,
|
||||
width: props.width,
|
||||
url: props.svgUrl,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Ошибка скачивания PNG:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto py-6 px-4 md:px-6">
|
||||
<div v-if="isLoading" class="text-gray-500">Загрузка инструкции...</div>
|
||||
<div v-else-if="data" class="markdown">
|
||||
<VueMarkdownIt :source="data" />
|
||||
</div>
|
||||
<p v-else-if="isError" class="text-red-500">Ошибка загрузки инструкции.</p>
|
||||
<p v-else class="text-gray-500">Инструкция для этого типа размещения отсутствует.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { VueMarkdownIt } from '@f3ve/vue-markdown-it'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { type QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
|
||||
// Импортируем все инструкции из директории
|
||||
const importInstructions = import.meta.glob('/src/content/placementInstructions/*.md', {
|
||||
as: 'raw',
|
||||
}) as unknown as Record<string, () => Promise<string>>
|
||||
|
||||
const { placement } = defineProps<{ placement: QRCodeDocument['placement'] }>()
|
||||
|
||||
// Хук для загрузки инструкции
|
||||
async function fetchInstructions(): Promise<string | undefined> {
|
||||
return importInstructions[`/src/content/placementInstructions/${placement}.md`]?.()
|
||||
}
|
||||
|
||||
// Используем vue-query для управления запросом
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ['placementInstructions', computed(() => placement)],
|
||||
queryFn: fetchInstructions,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown {
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1.7;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(h1) {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(h2) {
|
||||
font-size: 1.75rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(h3) {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(p) {
|
||||
margin-bottom: 1rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(ul) {
|
||||
list-style: disc;
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(ol) {
|
||||
list-style: decimal;
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(li) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(a) {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(blockquote) {
|
||||
border-left: 4px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
color: #6b7280;
|
||||
margin: 1rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(code) {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(pre) {
|
||||
background-color: #f9fafb;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
color: #111827;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(th),
|
||||
.markdown ::v-deep(td) {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown ::v-deep(th) {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<section class="max-w-prose w-full text-center">
|
||||
<!-- Контактная информация -->
|
||||
<div class="flex items-start bg-gray-100 border rounded-lg p-4">
|
||||
<!-- Аватарка Gravatar -->
|
||||
<div v-if="contactInfo.email" class="flex-shrink-0 mr-4">
|
||||
<img
|
||||
:src="gravatarUrl"
|
||||
alt="Аватар"
|
||||
class="w-16 h-16 rounded-full"
|
||||
title="Аватар владельца"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Контакты -->
|
||||
<div class="text-left flex-grow">
|
||||
<p v-if="contactInfo.name" class="text-lg font-semibold">{{ contactInfo.name }}</p>
|
||||
|
||||
<p v-if="contactInfo.phone" class="text-gray-700">
|
||||
Телефон:
|
||||
<a :href="`tel:${formattedPhone}`" class="underline text-blue-600">
|
||||
{{ contactInfo.phone }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p v-if="contactInfo.email" class="text-gray-700">
|
||||
Email:
|
||||
<a :href="`mailto:${contactInfo.email}`" class="underline text-blue-600">
|
||||
{{ contactInfo.email }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
v-if="hasContactInfo"
|
||||
label="Добавить в адресную книгу"
|
||||
icon="pi pi-user-plus"
|
||||
class="p-button-sm mt-4 p-button-outlined"
|
||||
@click="addToAddressBook"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="success" class="text-green-500 mt-4">Контакт успешно добавлен в адресную книгу!</p>
|
||||
<p v-if="error" class="text-red-500 mt-4">Ошибка добавления контакта: {{ error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import md5 from 'md5'
|
||||
import type { ContactInfo } from '@/types/DBDocumentTypes'
|
||||
import { sanitizeFileName } from '@/utils/sanitizeFileName'
|
||||
|
||||
const { contactInfo } = defineProps<{ contactInfo: ContactInfo }>()
|
||||
|
||||
const success = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Форматирование номера телефона для протокола `tel:`
|
||||
const formattedPhone = computed(() => {
|
||||
if (!contactInfo.phone) return ''
|
||||
return contactInfo.phone.replace(/[^0-9+]/g, '') // Удаляем все, кроме цифр и "+"
|
||||
})
|
||||
|
||||
// Проверка наличия контактной информации
|
||||
const hasContactInfo = computed(() => {
|
||||
return !!contactInfo.name || !!contactInfo.phone || !!contactInfo.email
|
||||
})
|
||||
|
||||
// Ссылка на аватар Gravatar
|
||||
const gravatarUrl = computed(() => {
|
||||
if (!contactInfo.email) return ''
|
||||
const hash = md5(contactInfo.email.trim().toLowerCase())
|
||||
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=128`
|
||||
})
|
||||
|
||||
// Добавление в адресную книгу
|
||||
function addToAddressBook() {
|
||||
try {
|
||||
const vCard = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
contactInfo.name ? `FN:${contactInfo.name}` : '',
|
||||
contactInfo.phone ? `TEL;TYPE=VOICE:${formattedPhone.value}` : '',
|
||||
contactInfo.email ? `EMAIL:${contactInfo.email}` : '',
|
||||
'END:VCARD',
|
||||
]
|
||||
.filter(Boolean) // Убираем пустые строки
|
||||
.join('\n')
|
||||
|
||||
const blob = new Blob([vCard], { type: 'text/vcard;charset=utf-8' })
|
||||
const link = document.createElement('a')
|
||||
|
||||
const sanitized = contactInfo.name && sanitizeFileName(contactInfo.name)
|
||||
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${sanitized || 'contact'}.vcf`
|
||||
link.click()
|
||||
|
||||
success.value = true
|
||||
error.value = null
|
||||
} catch {
|
||||
error.value = 'Ошибка при создании vCard'
|
||||
success.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<section class="max-w-prose w-full">
|
||||
<p class="text-gray-600 mb-4 text-center" v-if="doc.predefinedMessages?.length">
|
||||
Выберите сообщение из списка или напишите своё:
|
||||
</p>
|
||||
|
||||
<!-- Предустановленные сообщения -->
|
||||
<div v-if="doc.predefinedMessages?.length" class="mb-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<Button
|
||||
v-for="(message, index) in doc.predefinedMessages"
|
||||
:key="index"
|
||||
class="p-button-outlined w-full text-left"
|
||||
:disabled="loading"
|
||||
@click="sendPresetMessage(message)"
|
||||
>
|
||||
{{ message }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Текстовое поле ввода с кнопкой -->
|
||||
<form @submit.prevent="sendCustomMessage" class="flex gap-2 items-center">
|
||||
<InputText
|
||||
id="customMessage"
|
||||
v-model="customMessage"
|
||||
class="flex-1"
|
||||
placeholder="Напишите сообщение"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
icon="pi pi-send"
|
||||
class="p-button-md"
|
||||
:disabled="!customMessage.trim() || loading"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p v-if="success" class="text-green-500 mt-4 text-center">Сообщение отправлено!</p>
|
||||
<p v-if="error" class="text-red-500 mt-4 text-center">Ошибка: {{ error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { messagesDbLocal as db } from '@/api/dbs'
|
||||
import type { MessageDocument, QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import { ROUTE_NAMES } from '@/constants/routes'
|
||||
|
||||
const { doc } = defineProps<{
|
||||
doc: QRCodeDocument
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Локальные состояния
|
||||
const customMessage = ref('')
|
||||
const success = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Генерация идентификатора чата
|
||||
|
||||
// Функция для отправки сообщения
|
||||
async function sendMessage(content: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const chat = (new Date().getTime() - new Date(doc.created_at).getTime()).toString(36)
|
||||
|
||||
const message: MessageDocument = {
|
||||
_id: `message:${doc.uri}:${chat}:${new Date().toISOString()}`,
|
||||
type: 'message',
|
||||
qr_code_uri: doc.uri,
|
||||
chat,
|
||||
created_at: new Date().toISOString(),
|
||||
from: 'guest',
|
||||
body: content.trim(),
|
||||
}
|
||||
|
||||
// Сохраняем сообщение в локальную базу данных
|
||||
await db.put(message)
|
||||
|
||||
success.value = true
|
||||
error.value = null
|
||||
customMessage.value = ''
|
||||
|
||||
// Перенаправляем на страницу чата
|
||||
await router.push({ name: ROUTE_NAMES.PUBLIC_CHAT, params: { qr_code_uri: doc.uri, chat } })
|
||||
} catch {
|
||||
success.value = false
|
||||
error.value = 'Ошибка отправки сообщения'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для отправки предустановленного сообщения
|
||||
async function sendPresetMessage(message: string) {
|
||||
await sendMessage(message)
|
||||
}
|
||||
|
||||
// Функция для отправки пользовательского сообщения
|
||||
async function sendCustomMessage() {
|
||||
await sendMessage(customMessage.value)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user