move * to apps/frontend
Main daploy / deploy (push) Successful in 43s

This commit is contained in:
2024-11-21 01:15:06 +02:00
parent 3ac451e5f3
commit 11bba7a275
89 changed files with 35 additions and 23 deletions
@@ -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>