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
+6
View File
@@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
+33
View File
@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
test-results/
playwright-report/
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+67
View File
@@ -0,0 +1,67 @@
# hereconnect
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
# Install browsers for the first run
npx playwright install
# When testing on CI, must build the project first
npm run build
# Runs the end-to-end tests
npm run test:e2e
# Runs the tests only on Chromium
npm run test:e2e -- --project=chromium
# Runs the tests of a specific file
npm run test:e2e -- tests/example.spec.ts
# Runs the tests in debug mode
npm run test:e2e -- --debug
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
# Demo
Open [https://hereconnect.condev.ru/](https://hereconnect.condev.ru/) to see latest build from main branch
+55
View File
@@ -0,0 +1,55 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Button: typeof import('primevue/button')['default']
Card: typeof import('primevue/card')['default']
Chat: typeof import('./src/components/ChatComponent.vue')['default']
ChatComponent: typeof import('./src/components/ChatComponent.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default']
ContactInfo: typeof import('./src/components/read/ReadContactInfo.vue')['default']
CreateActionsSection: typeof import('./src/components/create/CreateActionsSection.vue')['default']
CreateDeliverySection: typeof import('./src/components/create/CreateMessageDeliveryMethodSection.vue')['default']
CreateMessageDeliveryMethodSection: typeof import('./src/components/create/CreateMessageDeliveryMethodSection.vue')['default']
CreateMessagesSection: typeof import('./src/components/create/CreatePredefinedMessagesSection.vue')['default']
CreateNameSection: typeof import('./src/components/create/CreateNameSection.vue')['default']
CreatePlacementSection: typeof import('./src/components/create/CreatePlacementSection.vue')['default']
CreatePredefinedMessagesSection: typeof import('./src/components/create/CreatePredefinedMessagesSection.vue')['default']
CreateReadySection: typeof import('./src/components/create/CreateReadySection.vue')['default']
CreateSection: typeof import('./src/components/create/CreateSection.vue')['default']
CreateSectionManager: typeof import('./src/components/create/CreateSectionManager.vue')['default']
CreateSummarySection: typeof import('./src/components/create/CreateSummarySection.vue')['default']
DownloadImageButton: typeof import('./src/components/manage/DownloadImageButton.vue')['default']
DownloadPng: typeof import('./src/components/manage/DownloadImageButton.vue')['default']
Dropdown: typeof import('primevue/dropdown')['default']
ExamplesSection: typeof import('./src/components/TheWelcome/ExamplesSection.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
IconEcosystem: typeof import('./src/components/icons/IconEcosystem.vue')['default']
IconSupport: typeof import('./src/components/icons/IconSupport.vue')['default']
IconTooling: typeof import('./src/components/icons/IconTooling.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
ManagePlacementInstructions: typeof import('./src/components/manage/ManagePlacementInstructions.vue')['default']
MessageSender: typeof import('./src/components/read/ReadMessageSender.vue')['default']
PlacementInstructions: typeof import('./src/components/manage/ManagePlacementInstructions.vue')['default']
QRCodeReadySection: typeof import('./src/components/create/CreateReadySection.vue')['default']
QueryRender: typeof import('./src/components/QueryRender.vue')['default']
RadioButton: typeof import('primevue/radiobutton')['default']
ReadContactInfo: typeof import('./src/components/read/ReadContactInfo.vue')['default']
ReadMessageSender: typeof import('./src/components/read/ReadMessageSender.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
SelectActions: typeof import('./src/components/create/SelectActions.vue')['default']
SelecteActions: typeof import('./src/components/create/SelecteActions.vue')['default']
SelectPlacement: typeof import('./src/components/create/SelectPlacement.vue')['default']
TheWelcome: typeof import('./src/components/TheWelcome/TheWelcome.vue')['default']
WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": ["./**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/');
await expect(page.locator('div.greetings > h1')).toHaveText('You did it!');
})
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+31
View File
@@ -0,0 +1,31 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
import pluginVitest from '@vitest/eslint-plugin'
import pluginPlaywright from 'eslint-plugin-playwright'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
...pluginVue.configs['flat/essential'],
...vueTsEslintConfig(),
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
skipFormatting,
]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>НаСвязи</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+8167
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@hereconnect/frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"engines": {
"node": ">=v20.10.0",
"pnpm": ">=8.15.0"
},
"dependencies": {
"@f3ve/vue-markdown-it": "^0.2.3",
"@primevue/forms": "^4.2.1",
"@primevue/themes": "^4.2.1",
"@tanstack/vue-query": "^5.60.6",
"events": "^3.3.0",
"md5": "^2.3.0",
"pinia": "^2.2.6",
"pouchdb-browser": "^9.0.0",
"pouchdb-find": "^9.0.0",
"primeicons": "^7.0.0",
"primevue": "^4.2.1",
"qr-code-styling": "^1.8.4",
"svgo": "^3.3.2",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-primeui": "^0.3.4",
"vue": "^3.5.12",
"vue-query": "^1.26.0",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@playwright/test": "^1.48.2",
"@primevue/auto-import-resolver": "^4.2.1",
"@tsconfig/node20": "^20.1.4",
"@types/jsdom": "^21.1.7",
"@types/md5": "^2.3.5",
"@types/node": "^20.17.6",
"@types/pouchdb-browser": "^6.1.5",
"@vitejs/plugin-vue": "^5.1.4",
"@vitest/eslint-plugin": "1.1.7",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.1.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"eslint-plugin-playwright": "^2.0.0",
"eslint-plugin-vue": "^9.30.0",
"jsdom": "^25.0.1",
"npm-run-all2": "^7.0.1",
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "~5.6.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.10",
"vite-plugin-vue-devtools": "^7.5.4",
"vitest": "^2.1.4",
"vue-tsc": "^2.1.10"
}
}
+110
View File
@@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+61
View File
@@ -0,0 +1,61 @@
self.addEventListener('push', (event) => {
const data = event.data.json()
console.log('Push событие получено:', data.type)
// Отображение уведомления
if (data.type === 'notification') {
event.waitUntil(
self.registration.showNotification(data.notification.title, data.notification.options).then(
() => {
console.log('Notification создан', data.notification.options.tag)
},
(e) => {
console.error('Notification ошибка создания', e)
},
),
)
} else if (data.type === 'cancelNotification') {
event.waitUntil(
self.registration
.getNotifications({ tag: data.notification.options.tag })
.then((notifications) => {
notifications.forEach((notification) => {
if (
notification.tag === data.notification.options.tag ||
notification.data.payload.tag === data.notification.options.data.payload.tag // удалить, если notification.tag поддерживают все
) {
notification.close()
console.log('Notification удален')
}
})
}),
)
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
// Открытие URL из данных уведомления
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client) {
console.log('клик на уведомление', event.notification.tag)
return Promise.all([
client.focus(),
client.postMessage({
type: 'notificationClick',
tag: event.notification.tag,
payload: event.notification.payload,
}),
])
}
}
if (clients.openWindow) {
return clients.openWindow(event.notification.data.url)
}
}),
)
})
+1
View File
@@ -0,0 +1 @@
{"name":"НаСвязи","short_name":"НаСвязи","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+4
View File
@@ -0,0 +1,4 @@
import PouchDB from 'pouchdb-browser'
import PouchDBFindPlugin from 'pouchdb-find'
export default PouchDB.plugin(PouchDBFindPlugin)
+18
View File
@@ -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,
},
)
+58
View File
@@ -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

+1
View File
@@ -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
+18
View File
@@ -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-код находится на улице, используйте стойкие к погодным условиям материалы.
- Разместите код так, чтобы он не мешал и не мог быть случайно повреждён.
+56
View File
@@ -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
}
}
+98
View File
@@ -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
+12
View File
@@ -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 }
})
+14
View File
@@ -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()
}
+79
View File
@@ -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>
+112
View File
@@ -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>
+7
View File
@@ -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">
Сделайте НаСвязи удобнее. Будем рады вашей&nbsp;<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">
Сделайте НаСвязи удобнее. Мы будем рады вашей&nbsp;<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">-->
<!-- Сделайте НаСвязи удобнее. Будем рады вашей&nbsp;<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">
Сделайте НаСвязи удобнее. Будем рады вашей&nbsp;<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>
+11
View File
@@ -0,0 +1,11 @@
import tailwindScrollbar from 'tailwind-scrollbar'
import tailwindcssPrimeUI from 'tailwindcss-primeui'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [tailwindScrollbar, tailwindcssPrimeUI],
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": ["./components.d.ts"],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}
+26
View File
@@ -0,0 +1,26 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import Components from 'unplugin-vue-components/vite'
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [PrimeVueResolver()],
}),
vueDevTools(),
],
define: {
global: 'window', // it is needed for PouchDB
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)