@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
import PouchDB from 'pouchdb-browser'
|
||||
import PouchDBFindPlugin from 'pouchdb-find'
|
||||
|
||||
export default PouchDB.plugin(PouchDBFindPlugin)
|
||||
@@ -0,0 +1,18 @@
|
||||
import PouchDB from '@/api/PouchDB'
|
||||
|
||||
import type { QRCodeDocument, MessageDocument } from '@/types/DBDocumentTypes'
|
||||
|
||||
export const qrCodeDbRemote = new PouchDB<QRCodeDocument>('https://hereconnect.condev.ru/api/qr', {
|
||||
skip_setup: true,
|
||||
})
|
||||
|
||||
export const qrCodeDbLocal = new PouchDB<QRCodeDocument>('qr')
|
||||
|
||||
export const messagesDbLocal = new PouchDB<MessageDocument>('messages')
|
||||
|
||||
export const messagesDbRemote = new PouchDB<MessageDocument>(
|
||||
'https://hereconnect.condev.ru/api/messages',
|
||||
{
|
||||
skip_setup: true,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import { qrCodeDbLocal, qrCodeDbRemote } from '@/api/dbs'
|
||||
|
||||
const EPOHE = 1731661630981
|
||||
|
||||
export const createQrCodeDocument = async (
|
||||
qrCodeDocPartial: Pick<
|
||||
QRCodeDocument,
|
||||
| 'name'
|
||||
| 'placement'
|
||||
| 'actions'
|
||||
//| 'user_uuid'
|
||||
| 'predefinedMessages'
|
||||
| 'messageDeliveryMethod'
|
||||
>,
|
||||
) => {
|
||||
const uri = Math.abs(Date.now() - EPOHE).toString(36)
|
||||
|
||||
const qrCodeDoc: QRCodeDocument = {
|
||||
_id: `qr_code:${uri}`,
|
||||
type: 'qr_code',
|
||||
uri,
|
||||
url: `https://hereconnect.condev.ru/read/${uri}`,
|
||||
created_at: new Date().toISOString(),
|
||||
...qrCodeDocPartial,
|
||||
}
|
||||
|
||||
const { rev } = await qrCodeDbLocal.put(qrCodeDoc)
|
||||
qrCodeDoc._rev = rev
|
||||
|
||||
const replicateResult = await qrCodeDbRemote.replicate.from(qrCodeDbLocal, {
|
||||
doc_ids: [qrCodeDoc._id],
|
||||
})
|
||||
|
||||
// Проверяем результат репликации
|
||||
if (!replicateResult.ok) {
|
||||
throw new Error('Ошибка репликации: операция завершилась с ошибкой.')
|
||||
}
|
||||
if (replicateResult.doc_write_failures > 0) {
|
||||
throw new Error(`Ошибка записи документов: ${replicateResult.doc_write_failures}`)
|
||||
}
|
||||
if (replicateResult.errors.length > 0) {
|
||||
throw new Error(
|
||||
`Ошибки репликации: ${replicateResult.errors.map((e: { message: string }) => e.message).join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
return qrCodeDoc
|
||||
}
|
||||
|
||||
export const getQrCodeDocument = (uri: string) => {
|
||||
return qrCodeDbLocal.get<QRCodeDocument>(`qr_code:${uri}`).catch((error) => {
|
||||
if (error.name === 'not_found') {
|
||||
return qrCodeDbRemote.get<QRCodeDocument>(`qr_code:${uri}`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path fill="#FB2500" stroke="None" d="M94 384c-10.69 10.9-19.59 23.73-29.75 35.25C57.4 429.31 33.45 446.87 54 455h65c61.67-2.91 126.65 7.03 185.33-7.67 117.65-27.97 185.84-172.81 132.59-281.25C390.54 54.32 238.47 9.09 139.7 77.7 34.91 139.96 15.23 294.73 94 384m236-226v177c-26.16-6.68-51.76-15.48-78-22 8.51-24.46 20.5-47.86 29.98-72.02 5.09-16.89-18.53-19.16-25.73-6.73-18.43 15.59-37.17 30.71-55.5 46.5-9.63 13.08-22.66.79-34.43-2.07-12.21-5.3-25.45-9.3-37.32-14.68 66.54-35.63 133.64-72.07 201-106Z"/></svg>
|
||||
|
After Width: | Height: | Size: 573 B |
@@ -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>
|
||||
@@ -0,0 +1,20 @@
|
||||
// Предустановленные сообщения по типу размещения
|
||||
export const defaultMessages = {
|
||||
car: [
|
||||
'Ваш автомобиль мешает',
|
||||
'Выключите свет',
|
||||
'Спустило колесо',
|
||||
'Подойдите к вашей машине',
|
||||
'Эвакуатор',
|
||||
],
|
||||
pet: ['Нашёлся ваш питомец'],
|
||||
door: ['Я рядом с дверью'],
|
||||
store: ['Подойдите к кассе', 'Вопрос по товару', 'Помощь с покупкой'],
|
||||
restaurant_table: [
|
||||
'Позовите официанта',
|
||||
'Принести меню',
|
||||
'Принести счёт',
|
||||
'Пожаловаться на обслуживание',
|
||||
],
|
||||
other: [],
|
||||
} as const
|
||||
@@ -0,0 +1,8 @@
|
||||
// Массив вариантов доставки
|
||||
export const deliveryOptions = [
|
||||
{
|
||||
label: 'В браузере (push-уведомления)',
|
||||
method: 'webPushSubscription',
|
||||
},
|
||||
{ label: 'Telegram', method: 'telegram' },
|
||||
] as const
|
||||
@@ -0,0 +1,8 @@
|
||||
export const qrCodePlacements = [
|
||||
{ type: 'car', label: 'Автомобиль' },
|
||||
{ type: 'pet', label: 'Питомец' },
|
||||
{ type: 'door', label: 'Входная дверь дома или квартиры' },
|
||||
{ type: 'store', label: 'Магазин' },
|
||||
{ type: 'restaurant_table', label: 'Стол в кафе или ресторане' },
|
||||
{ type: 'other', label: 'Другое' },
|
||||
] as const
|
||||
@@ -0,0 +1,18 @@
|
||||
export const ROUTE_NAMES = {
|
||||
HOME: 'home',
|
||||
CREATE: 'create',
|
||||
MANAGE_QR_CODE_READY: 'manage-qr-code-ready',
|
||||
READ_QR_CODE: 'read-qr-code',
|
||||
DONATE: 'donate',
|
||||
PUBLIC_CHAT: 'public-chat',
|
||||
// CREATE_ACTIONS: 'create-actions',
|
||||
// MANAGE_SETTINGS: 'manage-settings',
|
||||
// MANAGE_PROFILE: 'manage-profile',
|
||||
// MANAGE_CHAT: 'manage-chat',
|
||||
// PUBLIC_QR: 'public-qr',
|
||||
// HELP: 'help',
|
||||
// ABOUT: 'about',
|
||||
// CONTACT: 'contact',
|
||||
// PRIVACY_POLICY: 'privacy-policy',
|
||||
// TERMS_OF_SERVICE: 'terms-of-service',
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
### Инструкции по размещению на автомобиль:
|
||||
|
||||
**Где размещать:**
|
||||
- На боковом или заднем стекле автомобиля.
|
||||
|
||||
**Как разместить:**
|
||||
1. Найдите видимое место на стекле, которое будет хорошо видно прохожим, например, в углу заднего или бокового окна.
|
||||
2. Очистите поверхность стекла от пыли и грязи с помощью сухой или слегка влажной ткани.
|
||||
3. Если вы используете наклейку:
|
||||
- Убедитесь, что материал устойчив к влаге и выцветанию.
|
||||
- Аккуратно приклейте наклейку на стекло, разглаживая её от центра к краям, чтобы избежать появления пузырьков воздуха.
|
||||
4. Если вы используете пластину или рамку:
|
||||
- Проверьте, что крепление не повредит стекло.
|
||||
- Убедитесь, что QR-код надёжно зафиксирован и не шатается.
|
||||
5. Если QR-код нанесён с помощью трафарета или гравировки:
|
||||
- Проверьте, что код выполнен без дефектов и легко сканируется.
|
||||
- Очистите стекло от остатков краски или мусора.
|
||||
|
||||
**Важно:**
|
||||
- Убедитесь, что QR-код не блокирует обзор водителю.
|
||||
- Проверьте, что код можно легко сканировать снаружи автомобиля в любое время суток.
|
||||
- Если QR-код размещён внутри, убедитесь, что стекло чистое и не бликует.
|
||||
@@ -0,0 +1,14 @@
|
||||
### Инструкции по размещению на входную дверь дома или квартиры:
|
||||
**Где размещать:**
|
||||
- На входной двери или рядом с домофоном.
|
||||
- На двери или рядом с дверным звонком.
|
||||
|
||||
**Как разместить:**
|
||||
1. Найдите место рядом с дверным звонком или на самой двери, которое будет заметно для гостей.
|
||||
2. Очистите поверхность от пыли и грязи, чтобы QR-код держался крепче.
|
||||
3. Приклейте QR-код на дверь с помощью двустороннего скотча или водостойкого клея.
|
||||
4. Если вы не хотите приклеивать код, используйте рамку или подставку и разместите её на полке или возле двери.
|
||||
|
||||
**Важно:**
|
||||
- Проверьте, что QR-код не блокирует другие элементы двери.
|
||||
- Убедитесь, что его легко заметить и сканировать при любом освещении.
|
||||
@@ -0,0 +1,12 @@
|
||||
### Инструкции по размещению:
|
||||
**Где размещать:**
|
||||
- В зависимости от ситуации, на любом удобном и видимом месте.
|
||||
|
||||
**Как разместить:**
|
||||
1. Подберите место, где QR-код будет защищён от повреждений и виден окружающим.
|
||||
2. Используйте подходящие материалы для крепления: двусторонний скотч, рамку, подставку или брелок.
|
||||
3. Убедитесь, что QR-код хорошо заметен и его легко сканировать.
|
||||
|
||||
**Важно:**
|
||||
- Адаптируйте место размещения под конкретную задачу.
|
||||
- Проверьте, чтобы QR-код не был скрыт другими предметами.
|
||||
@@ -0,0 +1,13 @@
|
||||
### Инструкции по размещению на питомца:
|
||||
**Где размещать:**
|
||||
- На ошейнике питомца.
|
||||
|
||||
**Как разместить:**
|
||||
1. Выберите удобный ошейник с креплением для QR-кода. Лучше всего подойдут ошейники с кольцом или специальным держателем.
|
||||
2. Используйте прочный и водостойкий брелок или пластиковую подложку для QR-кода.
|
||||
3. Закрепите QR-код на ошейнике так, чтобы он не мешал вашему питомцу, но был легко доступен для сканирования.
|
||||
4. Проверьте, чтобы QR-код был хорошо зафиксирован и не потерялся во время прогулки.
|
||||
|
||||
**Важно:**
|
||||
- Используйте материалы, которые не вызовут аллергии или раздражения у питомца.
|
||||
- Проверьте, что QR-код можно легко сканировать в любом положении.
|
||||
@@ -0,0 +1,18 @@
|
||||
### Инструкции по размещению на стол в кафе или ресторане:
|
||||
**Где размещать:**
|
||||
- На столе, в меню или на подставке.
|
||||
|
||||
**Как разместить:**
|
||||
1. Если QR-код размещается на столе:
|
||||
- Используйте небольшой деревянный держатель или подставку.
|
||||
- Поставьте подставку в центр стола или сбоку, чтобы гости могли легко заметить код.
|
||||
2. Если QR-код размещается в меню:
|
||||
- Вставьте QR-код на видное место, например, на первую или последнюю страницу меню.
|
||||
- Используйте плотный ламинированный материал, чтобы код не повредился при использовании меню.
|
||||
3. Если QR-код размещается на подставке:
|
||||
- Поставьте подставку на уровне глаз сидящих гостей.
|
||||
- Проверьте, чтобы подставка была устойчива и не падала.
|
||||
|
||||
**Важно:**
|
||||
- Защищайте QR-код от влаги, например, от пролитых напитков.
|
||||
- Проверьте, что его можно легко сканировать, сидя за столом.
|
||||
@@ -0,0 +1,21 @@
|
||||
### Инструкции по размещению в магазине
|
||||
|
||||
**Где размещать:**
|
||||
- На витрине, входной двери, кассе или информационной стойке.
|
||||
|
||||
**Как разместить:**
|
||||
1. **Если QR-код размещается на витрине или входной двери:**
|
||||
- Найдите видное место на стекле, чтобы QR-код был хорошо заметен прохожим.
|
||||
- Очистите стекло от пыли и загрязнений.
|
||||
- Приклейте QR-код на внутреннюю сторону стекла, чтобы защитить его от погодных условий.
|
||||
2. **Если QR-код размещается на кассе:**
|
||||
- Используйте подставку или рамку, чтобы QR-код был на уровне глаз клиента.
|
||||
- Убедитесь, что он не мешает работе кассира.
|
||||
3. **Если QR-код размещается на информационной стойке:**
|
||||
- Прикрепите QR-код к стойке с помощью двустороннего скотча.
|
||||
- Либо используйте рамку, чтобы код выглядел аккуратно и профессионально.
|
||||
|
||||
**Важно:**
|
||||
- Убедитесь, что QR-код можно легко сканировать, даже при ярком свете или бликах.
|
||||
- Если QR-код находится на улице, используйте стойкие к погодным условиям материалы.
|
||||
- Разместите код так, чтобы он не мешал и не мог быть случайно повреждён.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createApp } from 'vue'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { createPinia } from 'pinia'
|
||||
import PouchDB from '@/api/PouchDB'
|
||||
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import './style.css'
|
||||
import 'primeicons/primeicons.css'
|
||||
|
||||
const MyPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
primary: {
|
||||
50: '{rose.50}',
|
||||
100: '{rose.100}',
|
||||
200: '{rose.200}',
|
||||
300: '{rose.300}',
|
||||
400: '{rose.400}',
|
||||
500: '{rose.500}',
|
||||
600: '{rose.600}',
|
||||
700: '{rose.700}',
|
||||
800: '{rose.800}',
|
||||
900: '{rose.900}',
|
||||
950: '{rose.950}',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { registerServiceWorker } from '@/registerServiceWorker'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(VueQueryPlugin, {
|
||||
enableDevtoolsV6Plugin: import.meta.env.DEV,
|
||||
})
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: MyPreset,
|
||||
options: {
|
||||
darkModeSelector: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
registerServiceWorker()
|
||||
.then(() => console.log('Service Worker успешно зарегистрирован'))
|
||||
.catch(console.error)
|
||||
|
||||
window.PouchDB = PouchDB // it for debug in console only
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Ваш браузер не поддерживает Service Worker.')
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/service-worker.js')
|
||||
console.log('Service Worker зарегистрирован:', registration)
|
||||
return registration
|
||||
} catch (error) {
|
||||
console.error('Ошибка регистрации Service Worker:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ROUTE_NAMES } from '@/constants/routes'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: ROUTE_NAMES.HOME,
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/create',
|
||||
name: ROUTE_NAMES.CREATE,
|
||||
component: () => import('@/views/CreateView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/manage/:qr_code_uri/ready',
|
||||
name: ROUTE_NAMES.MANAGE_QR_CODE_READY,
|
||||
component: () => import('@/views/ManageQRCodeReadyView.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/read/:qr_code_uri',
|
||||
name: ROUTE_NAMES.READ_QR_CODE,
|
||||
component: () => import('@/views/ReadQRCodeView.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/donate',
|
||||
name: ROUTE_NAMES.DONATE,
|
||||
component: () => import('@/views/DonateView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/chat/:qr_code_uri/:chat',
|
||||
name: ROUTE_NAMES.PUBLIC_CHAT,
|
||||
component: () => import('@/views/PublicChatView.vue'),
|
||||
props: true,
|
||||
},
|
||||
|
||||
// Закомментированные маршруты
|
||||
// {
|
||||
// path: '/create/actions',
|
||||
// name: ROUTE_NAMES.CREATE_ACTIONS,
|
||||
// component: () => import('@/views/CreateActionsView.vue'), // Второй шаг создания QR‑кода
|
||||
// },
|
||||
// {
|
||||
// path: '/manage/:qr_code/settings',
|
||||
// name: ROUTE_NAMES.MANAGE_SETTINGS,
|
||||
// component: () => import('@/views/SettingsView.vue'), // Настройки QR‑кода для владельца
|
||||
// },
|
||||
// {
|
||||
// path: '/manage/profile',
|
||||
// name: ROUTE_NAMES.MANAGE_PROFILE,
|
||||
// component: () => import('@/views/ProfileView.vue'), // Личный кабинет владельца
|
||||
// },
|
||||
// {
|
||||
// path: '/manage/:qr_code/:chat',
|
||||
// name: ROUTE_NAMES.MANAGE_CHAT,
|
||||
// component: () => import('@/views/ChatView.vue'), // Чат для владельца
|
||||
// },
|
||||
// {
|
||||
// path: '/:qr_code',
|
||||
// name: ROUTE_NAMES.PUBLIC_QR,
|
||||
// component: () => import('@/views/PublicQRView.vue'), // Публичная страница QR‑кода
|
||||
// },
|
||||
// {
|
||||
// path: '/help',
|
||||
// name: ROUTE_NAMES.HELP,
|
||||
// component: () => import('@/views/HelpView.vue'), // Страница помощи и FAQ
|
||||
// },
|
||||
// {
|
||||
// path: '/about',
|
||||
// name: ROUTE_NAMES.ABOUT,
|
||||
// component: () => import('@/views/AboutView.vue'), // Страница информации о компании и проекте
|
||||
// },
|
||||
// {
|
||||
// path: '/contact',
|
||||
// name: ROUTE_NAMES.CONTACT,
|
||||
// component: () => import('@/views/ContactView.vue'), // Страница с контактной информацией
|
||||
// },
|
||||
// {
|
||||
// path: '/privacy-policy',
|
||||
// name: ROUTE_NAMES.PRIVACY_POLICY,
|
||||
// component: () => import('@/views/PrivacyPolicyView.vue'), // Политика конфиденциальности
|
||||
// },
|
||||
// {
|
||||
// path: '/terms-of-service',
|
||||
// name: ROUTE_NAMES.TERMS_OF_SERVICE,
|
||||
// component: () => import('@/views/TermsOfServiceView.vue'), // Условия использования
|
||||
// },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
@layer tailwind-base, primevue, tailwind-utilities;
|
||||
|
||||
@layer tailwind-base {
|
||||
@tailwind base;
|
||||
}
|
||||
|
||||
@layer tailwind-utilities {
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Базовый интерфейс для всех документов в базе данных.
|
||||
* Содержит общие поля, такие как идентификатор, тип и дату создания.
|
||||
*/
|
||||
export interface BaseDocument {
|
||||
/** Уникальный идентификатор документа */
|
||||
_id: string
|
||||
|
||||
_rev?: string
|
||||
|
||||
/** Тип документа */
|
||||
type: string
|
||||
|
||||
/** Дата создания документа в формате ISO */
|
||||
created_at: string
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type BaseDocument } from './BaseDocument'
|
||||
|
||||
/**
|
||||
* Интерфейс для документа сообщения.
|
||||
* Используется для хранения сообщений, отправленных через QR‑код, с поддержкой вложений.
|
||||
*/
|
||||
export interface MessageDocument extends BaseDocument {
|
||||
/** Тип документа, всегда 'message' */
|
||||
type: 'message'
|
||||
|
||||
/** Идентификатор QR‑кода, к которому относится сообщение */
|
||||
qr_code_uri: string
|
||||
|
||||
/** Идентификатор чата для связывания сообщений */
|
||||
chat: string
|
||||
|
||||
/** Отправитель сообщения ('guest' или 'owner') */
|
||||
from: 'guest' | 'owner'
|
||||
|
||||
/** Содержимое сообщения */
|
||||
body: string
|
||||
|
||||
/** Опциональная геопозиция (широта и долгота) */
|
||||
location?: { lat: number; lng: number }
|
||||
|
||||
/** Вложения, такие как фото */
|
||||
// _attachments?: {
|
||||
// [key: string]: Attachment
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { BaseDocument } from './BaseDocument'
|
||||
|
||||
export type ContactInfo = {
|
||||
name?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Интерфейс для документа QR‑кода.
|
||||
* Представляет QR‑код, созданный пользователем, и информацию о привязанном объекте.
|
||||
*/
|
||||
export interface QRCodeDocument extends BaseDocument {
|
||||
/** Тип документа, всегда 'qr_code' */
|
||||
type: 'qr_code'
|
||||
|
||||
/** Уникальный URL для настройки и взаимодействия */
|
||||
uri: string
|
||||
|
||||
/** Ссылка на QR‑код */
|
||||
url: string
|
||||
|
||||
/** Идентификатор пользователя, который создал QR‑код */
|
||||
// user_uuid: string
|
||||
|
||||
/** Название объекта, для которого создан QR‑код, например "Мой автомобиль" */
|
||||
name: string
|
||||
|
||||
messageDeliveryMethod: 'telegram' | 'webPushSubscription'
|
||||
|
||||
/** Тип объекта, для которого создан QR‑код, например "car" */
|
||||
placement: 'car' | 'pet' | 'door' | 'store' | 'restaurant_table' | 'other'
|
||||
|
||||
/** Доступные действия для гостей (например, ["send_message"]) */
|
||||
actions: string[]
|
||||
|
||||
/** Предустановленное сообщение для гостей */
|
||||
predefinedMessages?: string[]
|
||||
|
||||
contactInfo?: ContactInfo
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BaseDocument } from './BaseDocument'
|
||||
|
||||
/**
|
||||
* Интерфейс для документа пользователя.
|
||||
* Содержит данные, такие как имя пользователя, уникальный UUID, email и роли.
|
||||
*/
|
||||
export interface UserDocument extends BaseDocument {
|
||||
/** Тип документа, всегда 'user' */
|
||||
type: 'user'
|
||||
|
||||
/** Имя пользователя */
|
||||
name: string
|
||||
|
||||
/** Уникальный UUID пользователя, не связанный с _id */
|
||||
user_uuid: string
|
||||
|
||||
/** Email пользователя */
|
||||
email: string
|
||||
|
||||
/** Роли пользователя (например, ['admin', 'user']) */
|
||||
roles: string[]
|
||||
|
||||
/** Пароль пользователя (должен храниться в зашифрованном виде) */
|
||||
password: string
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './BaseDocument'
|
||||
export * from './QRCodeDocument'
|
||||
export * from './MessageDocument'
|
||||
export * from './UserDocument'
|
||||
@@ -0,0 +1,24 @@
|
||||
export function calculateFontSizeForText(text: string, maxWidth: number): number {
|
||||
// Создаем временный элемент <span> для измерения текста
|
||||
const span = document.createElement('span')
|
||||
span.style.position = 'absolute'
|
||||
span.style.whiteSpace = 'nowrap'
|
||||
span.style.fontFamily = 'Arial, sans-serif' // Задаем нужный шрифт
|
||||
span.style.visibility = 'hidden'
|
||||
span.textContent = text
|
||||
document.body.appendChild(span)
|
||||
|
||||
// Начальный размер шрифта
|
||||
let fontSize = maxWidth
|
||||
span.style.fontSize = `${fontSize}px`
|
||||
|
||||
// Уменьшаем размер шрифта, пока ширина текста не впишется в maxWidth
|
||||
while (span.offsetWidth > maxWidth) {
|
||||
fontSize -= 1
|
||||
span.style.fontSize = `${fontSize}px`
|
||||
}
|
||||
|
||||
// Удаляем временный элемент
|
||||
document.body.removeChild(span)
|
||||
return fontSize
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export async function convertImageToBlob({
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
}: {
|
||||
url: string
|
||||
width?: number // Ширина необязательна
|
||||
height?: number // Высота необязательна
|
||||
type: string
|
||||
}): Promise<Blob> {
|
||||
if (!width && !height) {
|
||||
throw new Error('Необходимо указать хотя бы одно значение: width или height.')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// Вычисляем размеры, сохраняя пропорции
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight
|
||||
const targetWidth = width || height! * aspectRatio
|
||||
const targetHeight = height || width! / aspectRatio
|
||||
|
||||
// Создаем canvas и рисуем изображение
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(targetWidth)
|
||||
canvas.height = Math.round(targetHeight)
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (!context) {
|
||||
return reject(new Error('Ошибка создания контекста'))
|
||||
}
|
||||
context.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Конвертируем canvas в Blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Ошибка создания изображения'))
|
||||
}
|
||||
},
|
||||
type,
|
||||
1.0,
|
||||
)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Ошибка загрузки изображения'))
|
||||
}
|
||||
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { convertImageToBlob } from './convertImageToBlob'
|
||||
|
||||
export async function downloadImage({
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
filename,
|
||||
type,
|
||||
}: {
|
||||
url: string
|
||||
width?: number
|
||||
height?: number
|
||||
filename: string
|
||||
type: string
|
||||
}) {
|
||||
try {
|
||||
const blob = await convertImageToBlob({ url, width, height, type })
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = filename
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(link.href) // Очищаем URL после загрузки
|
||||
} catch (err) {
|
||||
console.error('Ошибка преобразования SVG в PNG:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { calculateFontSizeForText } from '@/utils/calculateFontSizeForText'
|
||||
|
||||
/**
|
||||
* Добавление текста в SVG, прибитого к нижнему краю.
|
||||
* @param svgText Исходный SVG в виде строки.
|
||||
* @param label Надпись для добавления.
|
||||
* @param color Цвет текста.
|
||||
* @param marginMultiplier Множитель относительного отступа.
|
||||
* @returns SVG с добавленным текстом.
|
||||
*/
|
||||
export function addLabelToSvg(
|
||||
svgText: string,
|
||||
label: string,
|
||||
color: string,
|
||||
marginMultiplier: number = 0.1,
|
||||
): string {
|
||||
if (!label) {
|
||||
return svgText
|
||||
}
|
||||
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml')
|
||||
const svgElement = svgDoc.querySelector('svg')
|
||||
|
||||
if (svgElement) {
|
||||
// Увеличиваем viewBox для текста
|
||||
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number)
|
||||
if (viewBox) {
|
||||
const fontSize = calculateFontSizeForText(label, viewBox[2])
|
||||
const textMargin = Math.min(fontSize, viewBox[3] * marginMultiplier)
|
||||
|
||||
viewBox[3] += fontSize + textMargin // Увеличиваем высоту
|
||||
svgElement.setAttribute('viewBox', viewBox.join(' '))
|
||||
|
||||
// Добавляем текст
|
||||
const textElement = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'text')
|
||||
textElement.setAttribute('x', '50%')
|
||||
textElement.setAttribute('y', '100%') // Устанавливаем Y на 100% высоты
|
||||
textElement.setAttribute('text-anchor', 'middle')
|
||||
textElement.setAttribute('alignment-baseline', 'baseline') // Привязываем к нижнему краю
|
||||
textElement.setAttribute('dominant-baseline', 'text-after-edge') // Альтернативный способ
|
||||
textElement.setAttribute('fill', color)
|
||||
textElement.setAttribute('font-size', fontSize.toString())
|
||||
textElement.setAttribute('font-family', 'Arial, sans-serif')
|
||||
textElement.textContent = label
|
||||
|
||||
svgElement.appendChild(textElement)
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(svgDoc)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { optimize } from 'svgo'
|
||||
|
||||
/**
|
||||
* Оптимизация SVG с использованием SVGO.
|
||||
* @param svgText string изображения SVG.
|
||||
* @returns Строка оптимизированного SVG.
|
||||
*/
|
||||
export function optimizeSvg(svgText: string): string {
|
||||
const optimizedSvg = optimize(svgText, {
|
||||
plugins: [
|
||||
{ name: 'removeDoctype' },
|
||||
{ name: 'removeComments' },
|
||||
{ name: 'removeDimensions' },
|
||||
{ name: 'convertShapeToPath' },
|
||||
{ name: 'convertPathData' },
|
||||
{ name: 'mergePaths' },
|
||||
],
|
||||
})
|
||||
|
||||
if (!optimizedSvg.data) {
|
||||
throw new Error('Ошибка оптимизации SVG')
|
||||
}
|
||||
|
||||
return optimizedSvg.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Заменяет атрибут `fill` в SVG-коде.
|
||||
* @param svgText Исходный SVG в виде строки.
|
||||
* @param fillColor Новый цвет для fill.
|
||||
* @returns Измененный SVG-код.
|
||||
*/
|
||||
export function replaceFillInSvg(svgText: string, fillColor: string): string {
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml')
|
||||
const paths = svgDoc.querySelectorAll('path, rect, circle, polygon, ellipse')
|
||||
|
||||
paths.forEach((path) => {
|
||||
if (path.hasAttribute('fill')) {
|
||||
path.setAttribute('fill', fillColor)
|
||||
}
|
||||
})
|
||||
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(svgDoc)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const sanitizeFileName = (fileName: string) => {
|
||||
return fileName
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '') // Убираем запрещённые символы
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="max-w-prose mx-auto py-6 px-4 md:px-6">
|
||||
<section class="content">
|
||||
<div class="max-w-prose mx-auto py-8">
|
||||
<CreateSectionManager
|
||||
:placement="placement"
|
||||
:selectedActions="selectedActions"
|
||||
:messageDeliveryMethod="messageDeliveryMethod"
|
||||
>
|
||||
<template #placement>
|
||||
<CreatePlacementSection v-model="placement" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<CreateActionsSection v-model="selectedActions" />
|
||||
</template>
|
||||
|
||||
<template #delivery>
|
||||
<CreateMessageDeliveryMethodSection v-model="messageDeliveryMethod" />
|
||||
</template>
|
||||
|
||||
<template #messages>
|
||||
<CreatePredefinedMessagesSection :placement="placement" v-model="messages" />
|
||||
</template>
|
||||
|
||||
<template #summary>
|
||||
<CreateSummarySection
|
||||
v-model:name="name"
|
||||
buttonLabel="Создать QR‑код"
|
||||
@nextStep="createQrCode"
|
||||
/>
|
||||
</template>
|
||||
</CreateSectionManager>
|
||||
|
||||
<p v-if="error" class="text-red-500 mt-4">Ошибка создания QR‑кода: {{ error }}</p>
|
||||
|
||||
<div class="h-[30dvh] md:h-[10dvh] overflow-hidden"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
≈
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createQrCodeDocument } from '@/api/qrCode'
|
||||
import type { QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import { ROUTE_NAMES } from '@/constants/routes'
|
||||
|
||||
const placement = ref<null | QRCodeDocument['placement']>(null)
|
||||
const selectedActions = ref<QRCodeDocument['actions']>([])
|
||||
const messageDeliveryMethod = ref<null | QRCodeDocument['messageDeliveryMethod']>(null)
|
||||
const messages = ref<Exclude<QRCodeDocument['predefinedMessages'], undefined>>([])
|
||||
const name = ref<QRCodeDocument['name']>('')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
async function createQrCode() {
|
||||
try {
|
||||
const qrCodeDocument = await createQrCodeDocument({
|
||||
name: name.value,
|
||||
placement: placement.value!,
|
||||
actions: selectedActions.value,
|
||||
messageDeliveryMethod: messageDeliveryMethod.value!,
|
||||
predefinedMessages: messages.value.length > 0 ? messages.value : undefined,
|
||||
})
|
||||
|
||||
error.value = null
|
||||
await router.push({
|
||||
name: ROUTE_NAMES.MANAGE_QR_CODE_READY,
|
||||
params: { qr_code_uri: qrCodeDocument.uri },
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message || 'Неизвестная ошибка'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<section class="max-w-2xl mx-auto py-8 px-4 text-center">
|
||||
<h1 class="text-2xl font-bold mb-4">Поддержите развитие НаСвязи</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Мы делаем всё, чтобы НаСвязи оставался удобным и полезным. Вы можете помочь нам, поддержав
|
||||
проект на Бусти.
|
||||
</p>
|
||||
|
||||
<!-- Ссылка на Бусти -->
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href="https://boosty.to/hereconnect/donate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block bg-orange-500 text-white font-semibold py-3 px-6 rounded-lg shadow hover:bg-orange-600 transition duration-300"
|
||||
>
|
||||
Поддержать через Бусти
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Рассказать друзьям -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Рассказать друзьям</h2>
|
||||
<p class="text-gray-600 mb-4">Поделитесь ссылкой на НаСвязи:</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<!-- Telegram -->
|
||||
<a
|
||||
:href="telegramShareUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-button-rounded p-button-text p-button-lg inline-flex items-center justify-center"
|
||||
>
|
||||
<i class="pi pi-telegram text-xl text-blue-500"></i>
|
||||
</a>
|
||||
|
||||
<!-- WhatsApp -->
|
||||
<a
|
||||
:href="whatsappShareUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-button-rounded p-button-text p-button-lg inline-flex items-center justify-center"
|
||||
>
|
||||
<i class="pi pi-whatsapp text-xl text-green-500"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Обратная связь -->
|
||||
<!-- <div>-->
|
||||
<!-- <h2 class="text-xl font-semibold mb-4">Оставить отзыв или идею</h2>-->
|
||||
<!-- <p class="text-gray-600 mb-4">Мы рады любым предложениям и обратной связи.</p>-->
|
||||
<!-- <form @submit.prevent="sendFeedback">-->
|
||||
<!-- <div class="mb-4">-->
|
||||
<!-- <InputText v-model="email" class="w-full" placeholder="Ваш email" />-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mb-4">-->
|
||||
<!-- <Textarea-->
|
||||
<!-- v-model="message"-->
|
||||
<!-- rows="4"-->
|
||||
<!-- class="w-full"-->
|
||||
<!-- placeholder="Ваше сообщение"-->
|
||||
<!-- ></Textarea>-->
|
||||
<!-- </div>-->
|
||||
<!-- <Button-->
|
||||
<!-- label="Отправить"-->
|
||||
<!-- class="p-button-md"-->
|
||||
<!-- :disabled="!email.trim() || !message.trim()"-->
|
||||
<!-- />-->
|
||||
<!-- </form>-->
|
||||
<!-- <p v-if="success" class="text-green-500 mt-4">Спасибо за ваше сообщение!</p>-->
|
||||
<!-- </div>-->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// import { ref } from 'vue'
|
||||
|
||||
// Локальные состояния
|
||||
// const email = ref('')
|
||||
// const message = ref('')
|
||||
// const success = ref(false)
|
||||
|
||||
// Конфигурация для шаринга
|
||||
const siteUrl = encodeURIComponent('https://hereconnect.condev.ru')
|
||||
const shareText = encodeURIComponent('Попробуйте НаСвязи — удобный способ связаться через QR-код!')
|
||||
// const shareImage = encodeURIComponent(
|
||||
// 'https://hereconnect.condev.ru/images/qr-code-logo-200x200.jpg',
|
||||
// )
|
||||
|
||||
// Генерация URL для Telegram
|
||||
const telegramShareUrl = `https://t.me/share/url?url=${siteUrl}&text=${shareText}` // &photo=${shareImage}`
|
||||
|
||||
// Генерация URL для WhatsApp
|
||||
const whatsappShareUrl = `https://api.whatsapp.com/send?text=${shareText}%0A${siteUrl}`
|
||||
|
||||
// // Обработка отправки отзыва
|
||||
// function sendFeedback() {
|
||||
// console.log('Отзыв отправлен', { email: email.value, message: message.value })
|
||||
// // Имитация отправки данных
|
||||
// email.value = ''
|
||||
// message.value = ''
|
||||
// success.value = true
|
||||
// setTimeout(() => (success.value = false), 5000) // Убираем сообщение через 5 секунд
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Стили для textarea */
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<main class="flex-grow flex items-center justify-center py-6 px-4 md:px-6">
|
||||
<section class="max-w-prose w-full flex flex-col gap-1">
|
||||
<h2 class="text-xl font-semibold mb-4">Ваш QR‑код готов</h2>
|
||||
<p class="text-gray-600 mb-4">Сохраните его и ознакомьтесь с инструкциями по размещению.</p>
|
||||
|
||||
<!-- Рендер QR‑кода -->
|
||||
<div v-if="!svgQueryIsLoading && svgQueryData" class="border p-4 bg-white rounded-lg mb-6">
|
||||
<img :src="svgQueryData" alt="QR‑код" />
|
||||
</div>
|
||||
<p v-else-if="svgQueryIsError" class="text-red-500 mt-4">
|
||||
Ошибка генерации QR‑кода: {{ svgQueryError?.message }}
|
||||
</p>
|
||||
|
||||
<!-- Сообщение об ошибке -->
|
||||
<p v-if="qrCodeQueryIsError" class="text-red-500 mt-4">
|
||||
Ошибка загрузки данных QR‑кода: {{ qrCodeQueryError!.message }}
|
||||
</p>
|
||||
<p v-if="svgQueryIsLoading" class="text-gray-500 mt-4">Загрузка...</p>
|
||||
|
||||
<!-- Кнопки для скачивания -->
|
||||
<div
|
||||
v-if="!svgQueryIsLoading && !qrCodeQueryIsError && svgQueryData"
|
||||
class="flex flex-row gap-4 justify-center"
|
||||
>
|
||||
<Button
|
||||
label="Скачать SVG"
|
||||
icon="pi pi-download"
|
||||
class="p-button-sm"
|
||||
:href="svgQueryData"
|
||||
:download="`${sanitizedFileName}.svg`"
|
||||
as="a"
|
||||
/>
|
||||
|
||||
<DownloadImageButton
|
||||
:svgUrl="svgQueryData"
|
||||
:filename="`${sanitizedFileName}.png`"
|
||||
label="Скачать PNG"
|
||||
type="image/png"
|
||||
:width="width"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Инструкции по размещению -->
|
||||
<ManagePlacementInstructions
|
||||
:placement="qrCodeDoc?.placement"
|
||||
v-if="qrCodeDoc?.placement"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="bg-gray-100 text-center py-4 border-t text-sm text-gray-700">
|
||||
<p>
|
||||
Нужен еще один?
|
||||
<a href="/create" class="underline">Создайте QR‑код</a>.
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
Сделайте НаСвязи удобнее. Будем рады вашей <a href="/donate" class="underline"
|
||||
>поддержке</a
|
||||
>.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import QRCodeStyling from 'qr-code-styling'
|
||||
import { optimizeSvg } from '@/utils/images/svg/optimizeSvg'
|
||||
import { replaceFillInSvg } from '@/utils/images/svg/replaceFillInSvg'
|
||||
import { getQrCodeDocument } from '@/api/qrCode'
|
||||
import { type QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import { addLabelToSvg } from '@/utils/images/svg/addLabelToSvg'
|
||||
import logo from '@/assets/logo.svg?raw'
|
||||
import { sanitizeFileName } from '@/utils/sanitizeFileName'
|
||||
|
||||
const { qr_code_uri } = defineProps<{ qr_code_uri: QRCodeDocument['uri'] }>()
|
||||
const color = ref('#000') // Цвет для текста QR-кода
|
||||
const logoColor = ref(color.value) // Цвет логотипа // #FB2500
|
||||
const label = ref('Сообщите о проблеме или задайте вопрос')
|
||||
const width = ref(256 * 4)
|
||||
const height = ref(width.value)
|
||||
|
||||
// Хук для загрузки данных QR‑кода
|
||||
const {
|
||||
data: qrCodeDoc,
|
||||
isError: qrCodeQueryIsError,
|
||||
error: qrCodeQueryError,
|
||||
} = useQuery({
|
||||
queryKey: ['qrCode', computed(() => qr_code_uri)],
|
||||
queryFn: () => getQrCodeDocument(qr_code_uri),
|
||||
})
|
||||
|
||||
const sanitizedFileName = computed(() => {
|
||||
const sanitized = qrCodeDoc.value?.name && sanitizeFileName(qrCodeDoc.value?.name)
|
||||
|
||||
return sanitized ? `${sanitized} QR-Code - НаСвязи` : 'QR-Code - НаСвязи'
|
||||
})
|
||||
|
||||
// Генерация и оптимизация SVG
|
||||
const {
|
||||
isLoading: svgQueryIsLoading,
|
||||
isError: svgQueryIsError,
|
||||
data: svgQueryData,
|
||||
error: svgQueryError,
|
||||
} = useQuery({
|
||||
queryKey: ['svg', computed(() => qr_code_uri)],
|
||||
enabled: computed(() => !!qrCodeDoc.value?.url),
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
// Заменяем цвет `fill` в логотипе
|
||||
const updatedLogo = replaceFillInSvg(logo, logoColor.value)
|
||||
|
||||
// Добавляем текст в логотип (если нужно)
|
||||
const logoWithText = addLabelToSvg(updatedLogo, 'НаСвязи', logoColor.value, 0.01)
|
||||
|
||||
const image = URL.createObjectURL(new Blob([logoWithText], { type: 'image/svg+xml' }))
|
||||
|
||||
// Инициализация QRCodeStyling
|
||||
const qrCode = new QRCodeStyling({
|
||||
width: width.value,
|
||||
height: height.value,
|
||||
data: qrCodeDoc.value!.url,
|
||||
image,
|
||||
imageOptions: {
|
||||
margin: 20,
|
||||
imageSize: 0.45,
|
||||
},
|
||||
type: 'svg',
|
||||
backgroundOptions: {
|
||||
color: 'transparent',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
dotsOptions: {
|
||||
color: color.value,
|
||||
type: 'classy-rounded',
|
||||
},
|
||||
})
|
||||
|
||||
const blob = await qrCode.getRawData('svg')
|
||||
if (blob && blob instanceof Blob) {
|
||||
const text = await blob.text()
|
||||
let resultSvg = optimizeSvg(text)
|
||||
if (!resultSvg) {
|
||||
throw new Error('Ошибка оптимизации SVG')
|
||||
}
|
||||
if (label.value) {
|
||||
resultSvg = addLabelToSvg(resultSvg, label.value, color.value)
|
||||
}
|
||||
return URL.createObjectURL(new Blob([resultSvg], { type: 'image/svg+xml' }))
|
||||
} else {
|
||||
throw new Error('Ошибка генерации QR‑кода')
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- Основной контент -->
|
||||
<main class="flex-grow flex flex-col">
|
||||
<section class="flex flex-col flex-grow max-w-prose w-full mx-auto">
|
||||
<ChatComponent userRole="guest" :qr_code_uri="qr_code_uri" :chat="chat">
|
||||
<template #endOfMessages="{ count }">
|
||||
<!-- Уведомление, если гость отправил только одно сообщение -->
|
||||
<div
|
||||
v-if="count === 1"
|
||||
class="flex flex-col items-center justify-center text-center bg-gray-50 border border-gray-200 rounded-lg shadow-md p-6 mt-6"
|
||||
>
|
||||
<p class="text-gray-700 font-medium text-lg">Ваше сообщение отправлено.</p>
|
||||
<p class="text-gray-600 mt-2">
|
||||
Вы можете написать ещё одно сообщение или подождать ответа.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-10 text-sm text-gray-500 text-center">
|
||||
Хотите создать собственный QR‑код?
|
||||
<a href="/create" class="underline">Создайте его здесь</a>.
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500 text-center">
|
||||
Сделайте НаСвязи удобнее. Мы будем рады вашей <RouterLink
|
||||
to="/donate"
|
||||
class="underline"
|
||||
>поддержке</RouterLink
|
||||
>.
|
||||
</p>
|
||||
</template>
|
||||
</ChatComponent>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Футер -->
|
||||
<!-- <footer class="bg-gray-100 text-center py-4 border-t text-sm text-gray-700">-->
|
||||
<!-- <p><a href="/create" class="underline">Создайте свой QR‑код</a>.</p>-->
|
||||
<!-- <p class="mt-1">-->
|
||||
<!-- Сделайте НаСвязи удобнее. Будем рады вашей <a href="/donate" class="underline"-->
|
||||
<!-- >поддержке</a-->
|
||||
<!-- >.-->
|
||||
<!-- </p>-->
|
||||
<!-- </footer>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MessageDocument } from '@/types/DBDocumentTypes'
|
||||
import ChatComponent from '@/components/ChatComponent.vue'
|
||||
defineProps<{ qr_code_uri: MessageDocument['qr_code_uri']; chat: MessageDocument['chat'] }>()
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<main class="flex-grow flex items-center justify-center py-6 px-4 md:px-6">
|
||||
<section class="max-w-prose w-full flex flex-col gap-10">
|
||||
<QueryRender :query #default="{ data: doc }">
|
||||
<template v-for="action in doc.actions" :key="action">
|
||||
<ReadContactInfo
|
||||
v-if="action === 'viewContactInfo' && doc.contactInfo"
|
||||
:contactInfo="doc.contactInfo"
|
||||
/>
|
||||
<ReadMessageSender v-else-if="action === 'sendMessage'" :doc />
|
||||
</template>
|
||||
</QueryRender>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="bg-gray-100 text-center py-4 border-t text-sm text-gray-700">
|
||||
<p>
|
||||
Нужен такой же?
|
||||
<a href="/create" class="underline">Создайте свой QR‑код</a>.
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
Сделайте НаСвязи удобнее. Будем рады вашей <a href="/donate" class="underline"
|
||||
>поддержке</a
|
||||
>.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { getQrCodeDocument } from '@/api/qrCode'
|
||||
import type { QRCodeDocument } from '@/types/DBDocumentTypes'
|
||||
import QueryRender from '@/components/QueryRender.vue'
|
||||
|
||||
const { qr_code_uri } = defineProps<{ qr_code_uri: string }>()
|
||||
|
||||
// Хук для загрузки данных QR-кода
|
||||
const query = useQuery({
|
||||
queryKey: ['qrCodeData', qr_code_uri],
|
||||
queryFn: (): Promise<QRCodeDocument> => getQrCodeDocument(qr_code_uri),
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user