This commit is contained in:
2026-01-28 22:37:27 +03:00
parent cbaa1cda9d
commit 53ca63ec91
64 changed files with 4375 additions and 2980 deletions

Binary file not shown.

2213
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,15 +20,16 @@
"axios": "^1.2.1", "axios": "^1.2.1",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"quasar": "^2.18.2", "quasar": "^2.18.6",
"vue": "^3.4.18", "sass-embedded": "^1.97.2",
"vue": "^3.5.27",
"vue-i18n": "^9.2.2", "vue-i18n": "^9.2.2",
"vue-router": "^4.0.12" "vue-router": "^4.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@intlify/unplugin-vue-i18n": "^2.0.0", "@intlify/unplugin-vue-i18n": "^2.0.0",
"@quasar/app-vite": "^2.3.0", "@quasar/app-vite": "^2.4.0",
"@quasar/quasar-app-extension-qmarkdown": "^2.0.5", "@quasar/quasar-app-extension-qmarkdown": "^2.0.5",
"@twa-dev/types": "^8.0.2", "@twa-dev/types": "^8.0.2",
"@types/node": "^20.17.30", "@types/node": "^20.17.30",

11
src/Global-properties.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { WebApp } from "@twa-dev/types"
declare module 'vue' {
export interface ComponentCustomProperties {
$tg: WebApp;
$isDesktop: boolean;
$isMobile: boolean;
}
}
export {}

View File

@@ -1,41 +0,0 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<style>
.x { fill: white; transform-box: fill-box; animation: expand-r 6s ease-in-out infinite;}
@keyframes expand-r { 50% { r: 8px; } }
.c { fill: white; transform-box: fill-box; animation: o 6s ease-in-out infinite; }
@keyframes o { 50% { transform: var(--t); } }
.l {
stroke: white;
stroke-dasharray: 1;
stroke-dashoffset: 1;
stroke-width: 1.5;
animation: draw 6s ease-in-out infinite;
}
@keyframes draw {
0%, 100% { stroke-dashoffset: 0; }
50% { stroke-dashoffset: 1; }
}
</style>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-1">
<stop stop-color="#2AABEE" offset="0%"></stop>
<stop stop-color="#229ED9" offset="100%"></stop>
</linearGradient>
</defs>
<rect width="32" height="32" rx="4" ry="4" fill="url(#linearGradient-1)" />
<circle class="x" cx="16" cy="16" r="6" style="--t:scale(1.2)" />
<circle class="c" cx="7" cy="7" r="3" style="--t:translate(9px,9px)"/>
<circle class="c" cx="28" cy="4" r="2" style="--t:translate(-12px,12px)"/>
<circle class="c" cx="6" cy="26" r="4" style="--t:translate(10px,-10px)"/>
<circle class="c" cx="26" cy="26" r="5" style="--t:translate(-10px,-10px)"/>
<line class="l" x1="16" y1="16" x2="7" y2="7" pathLength="1"/>
<line class="l" x1="16" y1="16" x2="28" y2="4" pathLength="1"/>
<line class="l" x1="16" y1="16" x2="6" y2="26" pathLength="1"/>
<line class="l" x1="16" y1="16" x2="26" y2="26" pathLength="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,6 +1,7 @@
import { boot } from 'quasar/wrappers' import { boot } from 'quasar/wrappers'
import pnPageWrapper from 'components/pnPageWrapper.vue'
import pnPageCard from 'components/pnPageCard.vue' import pnPageCard from 'components/pnPageCard.vue'
import pnScrollList from 'components/pnScrollList.vue' import pnPageTemplate from 'components/pnPageTemplate.vue'
import pnAutoAvatar from 'components/pnAutoAvatar.vue' import pnAutoAvatar from 'components/pnAutoAvatar.vue'
import pnOverlay from 'components/pnOverlay.vue' import pnOverlay from 'components/pnOverlay.vue'
import pnSmallDialog from 'components/pnSmallDialog.vue' import pnSmallDialog from 'components/pnSmallDialog.vue'
@@ -9,10 +10,12 @@ import pnShadowScroll from 'components/pnShadowScroll.vue'
import pnMagicOverlay from 'components/pnMagicOverlay.vue' import pnMagicOverlay from 'components/pnMagicOverlay.vue'
import pnAccountBlockName from 'components/pnAccountBlockName.vue' import pnAccountBlockName from 'components/pnAccountBlockName.vue'
import pnOnboardBtn from 'components/pnOnboardBtn.vue' import pnOnboardBtn from 'components/pnOnboardBtn.vue'
import pnBottomSheetDialog from 'components/pnBottomSheetDialog.vue'
const components = { const components = {
pnPageWrapper,
pnPageCard, pnPageCard,
pnScrollList, pnPageTemplate,
pnAutoAvatar, pnAutoAvatar,
pnOverlay, pnOverlay,
pnImageSelector, pnImageSelector,
@@ -20,7 +23,8 @@ const components = {
pnShadowScroll, pnShadowScroll,
pnMagicOverlay, pnMagicOverlay,
pnAccountBlockName, pnAccountBlockName,
pnOnboardBtn pnOnboardBtn,
pnBottomSheetDialog
} }
export default boot(({ app }) => { export default boot(({ app }) => {

View File

@@ -10,12 +10,43 @@ declare global {
} }
export default defineBoot(({ app }) => { export default defineBoot(({ app }) => {
if (window.Telegram?.WebApp) { const tg = window.Telegram?.WebApp
const webApp = window.Telegram.WebApp
webApp.ready() tg.ready()
sessionStorage.setItem('isTelegram', webApp.initData ? 'true' : 'false') sessionStorage.setItem('isTelegram', tg.initData ? 'true' : 'false')
webApp.SettingsButton.isVisible = true
app.config.globalProperties.$tg = webApp const platform = tg.platform.toLowerCase()
app.provide('tg', webApp) const isDesktopOrWeb = [
'tdesktop',
'tdlib',
'macos',
'web',
'weba',
'webk'
].includes(platform)
const isTablet = window.innerWidth > 1024
if (!isDesktopOrWeb && !isTablet) {
if (tg.isVersionAtLeast('8.0')) tg.requestFullscreen()
else tg.expand()
} }
tg.SettingsButton.show()
app.config.globalProperties.$tg = tg
app.config.globalProperties.$isDesktop = isDesktopOrWeb || isTablet
app.config.globalProperties.$isMobile = !isDesktopOrWeb && !isTablet
app.provide('tg', tg)
}) })
/* flag use
<div v-if="$isDesktop || isToolbarExpanded">
<q-tabs ...> </q-tabs>
</div>
script setup
import { getCurrentInstance } from 'vue'
const { proxy } = getCurrentInstance()
and use proxy.$isMobile
*/

View File

@@ -1,57 +1,72 @@
<template> <template>
<div <div class="flex row items-center no-wrap logo-component">
class="flex row items-center no-wrap logo-component" <LogoIcon
> v-bind="$attrs"
<img class="logo-svg"
src="src/assets/logo.svg" :class="{
style="height: 24px; max-width: 24px; padding-right: 2px;" 'is-animated': animated,
'hide-bg': !withBackground
}"
:style="{ color: iconColor }"
/> />
<span <div :class="'text-' + textColor" class="logo-text q-ml-xs">
class="text-brand" <span>tg</span>
style="margin-right: 0.05em;" <span class="text-bold">Crew</span>
> </div>
tg
</span>
<span class="text-brand text-bold">
Crew
</span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LogoIcon from 'components/BaseLogoSvg.vue'
interface Props {
textColor?: string
iconColor?: string
withBackground?: boolean
animated?: boolean
}
withDefaults(defineProps<Props>(), {
textColor: 'brand',
iconColor: 'white',
withBackground: true,
animated: true
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.logo-component { .logo-svg {
svg { height: 24px;
width: 1em; width: 24px;
height: 1em;
margin-right: 0.125em; :deep(.logo-bg) {
transition: opacity 0.3s;
} }
span { &.hide-bg :deep(.logo-bg) {
line-height: 1; display: none;
}
:deep(.x), :deep(.c) {
transform-box: fill-box;
}
&.is-animated {
:deep(.x) { animation: expand-r 6s ease-in-out infinite; }
:deep(.c) { animation: o 6s ease-in-out infinite; }
:deep(.l) {
stroke-dasharray: 1;
stroke-dashoffset: 1;
stroke-width: 1.5;
animation: draw 6s ease-in-out infinite;
}
} }
} }
.fill-brand { @keyframes expand-r { 50% { r: 8px; } }
fill: $brand; @keyframes o { 50% { transform: var(--t); } }
} @keyframes draw {
0%, 100% { stroke-dashoffset: 0; }
@keyframes blink { 50% { stroke-dashoffset: 1; }
100%,
0% {
fill: $brand2;
}
60% {
fill: $brand2;
opacity: 0.8;
}
}
#path5 {
animation: blink 3s infinite;
} }
</style> </style>

View File

@@ -0,0 +1,33 @@
<template>
<svg
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
v-bind="$attrs"
>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="logo-gradient">
<stop stop-color="#2AABEE" offset="0%"></stop>
<stop stop-color="#229ED9" offset="100%"></stop>
</linearGradient>
</defs>
<rect class="logo-bg" width="32" height="32" rx="4" ry="4" fill="url(#logo-gradient)" />
<circle class="x" cx="16" cy="16" r="6" style="--t:scale(1.2)" fill="currentColor" />
<circle class="c" cx="7" cy="7" r="3" style="--t:translate(9px,9px)" fill="currentColor" />
<circle class="c" cx="28" cy="4" r="2" style="--t:translate(-12px,12px)" fill="currentColor" />
<circle class="c" cx="6" cy="26" r="4" style="--t:translate(10px,-10px)" fill="currentColor" />
<circle class="c" cx="26" cy="26" r="5" style="--t:translate(-10px,-10px)" fill="currentColor" />
<line class="l" x1="16" y1="16" x2="7" y2="7" pathLength="1" stroke="currentColor" />
<line class="l" x1="16" y1="16" x2="28" y2="4" pathLength="1" stroke="currentColor" />
<line class="l" x1="16" y1="16" x2="6" y2="26" pathLength="1" stroke="currentColor" />
<line class="l" x1="16" y1="16" x2="26" y2="26" pathLength="1" stroke="currentColor" />
</svg>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
</script>

View File

@@ -39,7 +39,6 @@
const modelValue = defineModel<('terms'| 'consent')[]>({ default: () => [] }) const modelValue = defineModel<('terms'| 'consent')[]>({ default: () => [] })
const emit = defineEmits(['agree'])
const router = useRouter() const router = useRouter()
</script> </script>

View File

@@ -1,9 +1,9 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t(title) }} {{ $t(title) }}
</template> </template>
<template #footer> <template #card-actions>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100 fix-disabled-btn" class="w100 fix-disabled-btn"
@@ -13,52 +13,51 @@
{{ $t(btnText) }} {{ $t(btnText) }}
</q-btn> </q-btn>
</template> </template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm w100">
<slot name="myCompany"/>
<pn-image-selector <div class="flex column items-center q-pa-md q-pb-sm w100">
v-model="companyMod.logo" <slot name="myCompany"/>
:size="100"
:iconsize="80"
class="q-pb-lg"
/>
<q-form <pn-image-selector
ref="formRef" v-model="companyMod.logo"
@validation-success="isFormValid = true" :size="100"
@validation-error="isFormValid = false" :iconsize="80"
class="q-gutter-y-lg w100" class="q-pb-lg"
/>
<q-form
ref="formRef"
@validation-success="isFormValid = true"
@validation-error="isFormValid = false"
class="q-gutter-y-lg w100"
>
<q-input
v-for="input in textInputs"
:key="input.id"
:model-value="companyMod[input.val]"
@update:model-value="handleInput(input.val, $event, input.useTrim)"
dense
filled
class="w100 fix-bottom-padding"
:class="input.val === 'name' ? '' : 'q-pt-sm'"
:autogrow="input.val === 'description' || input.val === 'address'"
:label="input.label ? $t(input.label) : void 0"
:rules="input.rules"
no-error-icon
:label-slot="Boolean(input.label)"
debounce="300"
> >
<template #prepend>
<q-input <q-icon v-if="input.icon" :name="input.icon"/>
v-for="input in textInputs" </template>
:key="input.id" <template #label v-if="input.label">
:model-value="companyMod[input.val]" {{$t(input.label) }}
@update:model-value="handleInput(input.val, $event, input.useTrim)" <span v-if="input.val === 'name'" class="text-red">*</span>
dense </template>
filled </q-input>
class="w100 fix-bottom-padding" </q-form>
:class="input.val === 'name' ? '' : 'q-pt-sm'" </div>
:autogrow="input.val === 'description' || input.val === 'address'" </pn-page-template>
:label="input.label ? $t(input.label) : void 0"
:rules="input.rules"
no-error-icon
:label-slot="Boolean(input.label)"
debounce="300"
>
<template #prepend>
<q-icon v-if="input.icon" :name="input.icon"/>
</template>
<template #label v-if="input.label">
{{$t(input.label) }}
<span v-if="input.val === 'name'" class="text-red">*</span>
</template>
</q-input>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,16 +1,15 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t(title) }} {{ $t(title) }}
</template> </template>
<pn-scroll-list>
<markdown-viewver <markdown-viewver
class="q-pa-md" class="q-px-md"
:locale :locale
:documentName :documentName
/> />
</pn-scroll-list> </pn-page-template>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -9,12 +9,7 @@
{{ customer.email }} {{ customer.email }}
</span> </span>
<span v-else class="ellipsis"> <span v-else class="ellipsis">
{{ {{ nameTgUser(tgUser) }}
tgUser?.first_name +
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
tgUser?.last_name +
(!(tgUser?.first_name || tgUser?.last_name) ? tgUser?.username : '')
}}
</span> </span>
</div> </div>
</template> </template>
@@ -22,13 +17,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue' import { inject } from 'vue'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import type { WebApp } from '@twa-dev/types' import { nameTgUser } from 'utils/helpers'
import type { WebApp, WebAppUser } from '@twa-dev/types'
const authStore = useAuthStore() const authStore = useAuthStore()
const customer = authStore.customer const customer = authStore.customer
const tg = inject('tg') as WebApp const tg = inject('tg') as WebApp
const tgUser = tg.initDataUnsafe.user const tgUser = tg.initDataUnsafe.user as WebAppUser | undefined
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@@ -1,11 +1,13 @@
<template> <template>
<div <div
class="flex row q-mx-sm q-my-md justify-between items-center no-wrap" class="row no-wrap q-px-md q-py-md justify-between items-center bg-trasparent w100"
> >
<q-btn <q-btn
v-if="showCalendarBtn" v-if="showCalendarBtn"
flat round round
:color="calendarActive ? 'primary' : 'grey'" :color="calendarActive ? 'primary' : 'grey-2'"
:class="calendarActive ? 'text-white' : 'text-grey'"
class="q-mr-sm"
@click="emit('toggle-calendar')" @click="emit('toggle-calendar')"
> >
<div> <div>
@@ -21,26 +23,30 @@
</div> </div>
</q-btn> </q-btn>
<q-input <div class="w100">
v-model="search" <q-input
clearable v-model="search"
clear-icon="close" clearable
borderless clear-icon="close"
filled borderless
:placeholder="$t(placeholder)" filled
dense :placeholder="$t(placeholder)"
class="col-grow q-mx-sm" dense
> class="rounded-fix bg-white shadow-1"
<template #prepend> >
<q-icon name="mdi-magnify" color="grey"/> <template #prepend>
</template> <q-icon name="mdi-magnify" color="grey"/>
</q-input> </template>
</q-input>
</div>
<q-btn <q-btn
v-if="showFilterBtn" v-if="showFilterBtn"
@click="emit('open-filters')" @click="emit('open-filters')"
flat round round
:color="filterActive ? 'primary' : 'grey'" :color="filterActive ? 'primary' : 'grey-2'"
:class="filterActive ? 'text-white' : 'text-grey'"
class="q-ml-sm"
> >
<div> <div>
<q-icon name="mdi-filter-outline" size="sm"/> <q-icon name="mdi-filter-outline" size="sm"/>

View File

@@ -1,98 +1,125 @@
<template> <template>
<q-dialog <q-dialog
v-model="modelValue" v-model="modelValue"
maximized
transition-show="slide-up" transition-show="slide-up"
transition-hide="slide-down" transition-hide="slide-down"
position="bottom" position="bottom"
> >
<q-card <q-card
class="fix-card-width flex column no-scroll no-wrap q-px-none" ref="cardRef"
class="fix-card-width column no-scroll no-wrap q-px-none overflow-hidden relative-position"
style=" style="
border-top-left-radius: var(--top-radius) !important; border-top-left-radius: var(--top-radius) !important;
border-top-right-radius: var(--top-radius) !important; border-top-right-radius: var(--top-radius) !important;
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px - var(--tg-content-safe-area-inset-top) - var(--tg-safe-area-inset-top));
" "
> >
<div <div ref="cardHeaderRef" class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md">
ref="cardHeaderRef" <q-resize-observer @resize="onHeaderResize" />
class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md" <div class="flex column q-mx-xs">
> <span class="text-h6" style="line-height: 1.5rem;">
<div class="text-h6 q-mx-xs ellipsis">{{ $t(title) }}</div> {{ $t(title) }}
<div class="flex items-center justify-between no-wrap"> </span>
<slot name="btnSlot"> <span v-if="caption" class="text-grey text-caption">
<q-btn {{ $t(caption) }}
icon="mdi-close" </span>
@click="modelValue = false"
flat round
/>
</slot>
</div>
</div> </div>
<div <div class="flex items-center justify-between no-wrap">
ref="cardBodyRef" <slot name="btnSlot">
class="q-px-none q-ma-none" <q-btn icon="mdi-close" @click="modelValue = false" flat round />
> </slot>
<pn-shadow-scroll
:hideShadows="false"
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-pa-none q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div> </div>
<div </div>
style="overflow: hidden"
:style="{ height: footerHeight + 'px'}"
>
<div ref="cardFooterInnerRef" class="q-pa-md">
<q-resize-observer @resize="updateDimensions" />
<slot name="footer"/>
</div>
</div>
</q-card>
<div class="q-px-none q-ma-none">
<pn-shadow-scroll
:height="bodyHeight"
:bottomOffset
:topOffset
>
<div class="q-pa-none q-ma-none">
<q-resize-observer @resize="onInnerBodyResize" />
<slot />
</div>
</pn-shadow-scroll>
</div>
<div v-if="hasFooter" class="absolute-bottom">
<q-resize-observer @resize="onFooterResize" />
<div class="q-pa-md">
<slot name="footer" />
</div>
<div :style="{ height: tgInsetBottom + 'px' }" />
</div>
</q-card>
</q-dialog> </q-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, useSlots } from 'vue'
import { useSlots } from 'vue'
const modelValue = defineModel<boolean>({ const modelValue = defineModel<boolean>({ required: true })
required: true const props = withDefaults(defineProps<{
})
defineProps<{
title: string title: string
}>() caption?: string
maximized?: boolean
}>(), { maximized: true })
const slots = useSlots() const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null) const cardRef = ref()
const cardFooterInnerRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0) const headerHeight = ref(0)
const footerHeight = ref(0) const footerHeight = ref(0)
const bodyInnerHeight = ref(0) const bodyInnerHeight = ref(0)
const bodyHeight = ref(0) const tgInsetBottom = ref(0)
const topOffset = 16
const updateDimensions = async () => { const windowHeight = ref(window.innerHeight)
await nextTick(() => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0 const hasFooter = computed(() => !!slots['footer'])
footerHeight.value = cardFooterInnerRef.value?.offsetHeight || 0 const bottomOffset = computed(() => hasFooter.value ? footerHeight.value : tgInsetBottom.value)
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
const needScroll = headerHeight.value + footerHeight.value + bodyInnerHeight.value > window.innerHeight - 48 const bodyHeight = computed(() => {
bodyHeight.value = needScroll // hack for update computed params on window resize
? window.innerHeight - 48 - headerHeight.value - footerHeight.value const _trigger = windowHeight.value //eslint-disable-line
: bodyInnerHeight.value + 16 const el = cardRef.value?.$el
}) if (!el) return 0
const maxH = parseFloat(window.getComputedStyle(el).maxHeight) || 0
return props.maximized
? maxH - headerHeight.value
: Math.min(
bodyInnerHeight.value + topOffset + (hasFooter.value ? footerHeight.value : 0),
maxH - headerHeight.value
)
})
interface SizeParams { height: number; width: number }
const onHeaderResize = (size: SizeParams) => headerHeight.value = size.height
const onFooterResize = (size: SizeParams) => footerHeight.value = hasFooter.value ? size.height : 0
const onInnerBodyResize = (size: SizeParams) => bodyInnerHeight.value = size.height
const handleWindowResize = () => {
windowHeight.value = window.innerHeight
} }
watch(() => slots.body?.(), updateDimensions, { flush: 'post' }) const getTgInsetBottom = () => {
watch(modelValue, updateDimensions) const value = getComputedStyle(document.documentElement)
.getPropertyValue('--tg-safe-area-inset-bottom')
.trim()
tgInsetBottom.value = parseFloat(value) || 0
}
onMounted(() => getTgInsetBottom())
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
getTgInsetBottom()
})
onUnmounted(() => window.removeEventListener('resize', handleWindowResize))
</script> </script>

View File

@@ -0,0 +1,64 @@
<template>
<!-- <div :style="{ visibility: !modelValue ? 'inherit' : 'hidden' }">
<slot name="btn"/>
</div> -->
<!-- <div
:class="modelValue ? 'absolute' : ''"
:style="{ left: fabPosLeft + 'px', top: fabPosTop + 'px' }"
>
<slot name="btn"/>
</div> -->
<q-dialog v-model="modelValue" :transition-duration="0">
<div class="position-relative q-px-md">
<q-card
class="q-pa-none q-mx-md no-scroll rounded-card absolute"
:style="{ left: 0, top: fabPosTop - cardHeight - 16 + 'px' }"
align="center"
>
<q-resize-observer @resize="onCardResize"/>
<slot name="card"/>
</q-card>
<div
class="absolute bg-red"
:style="{ left: fabPosLeft + 100+ 'px', top: fabPosTop +100+ 'px', height: '100px', width: '100px' }"
id="fabDialog"
>
{{ fabPosLeft }} {{ fabPosTop }}
</div>
</div>
</q-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const modelValue = defineModel<boolean>({
required: true
})
withDefaults(defineProps<{
fabPosLeft?: number
fabPosTop?: number
}>(),
{
fabPosLeft: 0,
fabPosTop: 0
}
)
interface sizeParams {
height: number,
width: number
}
const cardHeight = ref(0)
function onCardResize (size: sizeParams) {
cardHeight.value = size.height
}
</script>
<style scoped>
</style>

121
src/components/pnGlass.vue Normal file
View File

@@ -0,0 +1,121 @@
<template>
<div class="glass-wrapper">
<div class="glass-effect" :style="glassStyle"></div>
<div class="glass-tint"></div>
<div class="glass-shine"></div>
<div class="glass-slot">
<slot/>
</div>
<svg style="display: none">
<filter
id="glass-distortion"
x="0%"
y="0%"
width="100%"
height="100%"
filterUnits="objectBoundingBox"
>
<feTurbulence
type="fractalNoise"
baseFrequency="0.01 0.01"
numOctaves="1"
seed="3"
result="turbulence"
/>
<!-- Seeds: 14, 17, -->
<feComponentTransfer in="turbulence" result="mapped">
<feFuncR type="gamma" amplitude="1" exponent="10" offset="0.5" />
<feFuncG type="gamma" amplitude="0" exponent="1" offset="0" />
<feFuncB type="gamma" amplitude="0" exponent="1" offset="0.5" />
</feComponentTransfer>
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
<feSpecularLighting
in="softMap"
surfaceScale="5"
specularConstant="1"
specularExponent="100"
lighting-color="white"
result="specLight"
>
<fePointLight x="-200" y="-200" z="300" />
</feSpecularLighting>
<feComposite
in="specLight"
operator="arithmetic"
k1="0"
k2="1"
k3="1"
k4="0"
result="litImage"
/>
<feDisplacementMap
in="SourceGraphic"
in2="softMap"
scale="150"
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
color?: string
}>()
const glassStyle = computed(() => ({
'--glass-color': props.color ?? 'white'
}))
</script>
<style scoped lang="scss">
.glass-wrapper {
display: flex;
position: relative;
overflow: hidden;
inset: 0;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 2.2);
}
.glass-effect {
position: absolute;
z-index: 0;
inset: 0;
backdrop-filter: blur(3px);
/* filter: url(#glass-distortion); */
overflow: hidden;
}
.glass-tint {
z-index: 1;
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.8);
}
.glass-shine {
position: absolute;
inset: 0;
z-index: 2;
overflow: hidden;
box-shadow: inset 2px 2px 1px 0 rgba(255, 255, 255, 0.5),
inset 0px 0px 1px 1px rgba(255, 255, 255, 0.5);
}
.glass-slot {
z-index: 3;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div>
<div
class="q-pa-none"
:class="typeClass"
:style="{ backgroundColor: bgColor ? getColorWithOpacity(color, bgColor) : getColorWithOpacity(color) }"
>
<slot/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getColorWithOpacity } from 'utils/colors'
const props = defineProps<{
type: string
color: string
bgColor?: string
}>()
const typeClass = computed(() => {
switch (props.type) {
case 'fab': return 'q-btn--fab q-btn--rounded'
case 'rounded': return 'q-btn--rounded'
default: return ''
}
})
</script>
<style scoped>
</style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<q-item <q-item
@click="showDialog=true" @click="showDialog = true"
clickable clickable
v-ripple v-ripple
> >
@@ -26,71 +26,19 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-dialog <pn-bottom-sheet-dialog
:title
:caption
v-model="showDialog" v-model="showDialog"
maximized
transition-show="slide-up"
transition-hide="slide-down"
position="bottom"
> >
<q-card <div class="q-px-md">
class="fix-card-width flex column no-scroll no-wrap q-px-none" <slot/>
style=" </div>
border-top-left-radius: var(--top-radius) !important; </pn-bottom-sheet-dialog>
border-top-right-radius: var(--top-radius) !important;
"
>
<div
ref="cardHeaderRef"
class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md"
>
<div>
<div class="flex column q-mx-xs">
<span
class="text-h6"
style="line-height: 1.5rem;"
>
{{ $t(title) }}
</span>
<span v-if="caption" class="text-grey text-caption">{{ $t(caption) }}</span>
</div>
</div>
<div class="flex items-center justify-between no-wrap">
<q-btn
icon="mdi-close"
@click="showDialog=false"
flat round
/>
</div>
</div>
<div
ref="cardBodyRef"
class="q-px-none q-ma-none"
>
<pn-shadow-scroll
:hideShadows="false"
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-px-md q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div>
<div
ref="cardFooterRef"
class="q-pa-md"
>
<slot name="footer"/>
</div>
</q-card>
</q-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref } from 'vue'
import { useSlots } from 'vue'
defineProps<{ defineProps<{
title: string title: string
@@ -100,31 +48,7 @@
}>() }>()
const showDialog=ref<boolean>(false) const showDialog=ref<boolean>(false)
const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null)
const cardFooterRef = ref<HTMLElement | null>(null)
const cardBodyRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
const footerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const updateDimensions = () => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0
footerHeight.value = cardFooterRef.value?.offsetHeight || 0
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
bodyHeight.value = window.innerHeight - headerHeight.value - footerHeight.value - 48
}
watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
</script> </script>
<style scoped> <style scoped>
.fix-card-width {
width: var(--body-width) !important;
}
</style> </style>

View File

@@ -1,32 +1,32 @@
<template> <template>
<div class="flex w100 column q-pt-xl q-pa-md"> <div class="flex w100 column q-pt-xl q-pa-md">
<div class="flex column justify-center col-grow items-center text-grey"> <div class="flex column justify-center col-grow items-center text-grey">
<q-btn <q-btn
flat no-caps flat no-caps
@click="handleClick" @click="handleClick"
v-if="!noBtn" v-if="!noBtn"
> >
<div class="flex column justify-center col-grow items-center"> <div class="flex column justify-center col-grow items-center">
<q-icon :name="icon" size="160px" class="q-pb-md"/> <q-icon :name="icon" size="160px" class="q-pb-md"/>
<div class="text-h6 text-brand text-center"> <div class="text-h6 text-brand text-center">
{{ message1 }} {{ $t(message1) }}
</div>
</div>
</q-btn>
<div v-else>
<div class="flex column justify-center col-grow items-center">
<q-icon :name="icon" size="160px" class="q-pb-md"/>
<div class="text-h6 text-brand text-center">
{{ message1 }}
</div>
</div> </div>
</div> </div>
</q-btn>
<div v-if="message2" class="text-caption text-center"> <div v-else>
{{ message2 }} <div class="flex column justify-center col-grow items-center">
<q-icon :name="icon" size="160px" class="q-pb-md"/>
<div class="text-h6 text-brand text-center">
{{ $t(message1) }}
</div>
</div> </div>
</div> </div>
<div v-if="message2" class="text-caption text-center">
{{ $t(message2) }}
</div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -44,5 +44,6 @@
} }
</script> </script>
<style>
<style scoped>
</style> </style>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div <div
id="overlay" id="overlay"
class="fixed-full q-dialog__backdrop" class="q-dialog__backdrop absolute-top"
style="z-index: 5000; --q-transition-duration: 300ms;" style="z-index: 5000; --q-transition-duration: 300ms; height: 100vh; width: 10vw;"
@click.prevent.stop @click.prevent.stop
@touchstart.prevent.stop @touchstart.prevent.stop
@touchmove.prevent.stop @touchmove.prevent.stop
@@ -13,6 +13,5 @@
<script setup lang="ts"> <script setup lang="ts">
</script> </script>
<style> <style scoped>
</style> </style>

View File

@@ -1,24 +1,117 @@
<template> <template>
<div <div
class="flex items-center justify-between q-ma-none q-py-none q-px-md text-white text-h6 no-scroll no-wrap w100" class="w100 col-grow relative-position top-rounded-card bg-white overflow-hidden"
style="min-height: 48px"
> >
<slot name="title"/> <q-resize-observer @resize="onCardResize"/>
<div
id="card-header"
class="absolute-top"
v-if="hasHeader"
style="z-index: 100;"
>
<q-resize-observer @resize="onHeaderResize"/>
<slot name="card-header"/>
</div>
<div
id="card-under-header"
class="absolute-top"
v-if="hasUnderHeader"
style="z-index: 100;"
:style="{ top: headerHeight + 'px' }"
>
<slot name="card-under-header"/>
</div>
<pn-shadow-scroll
:height="cardHeight"
:topOffset="headerHeight"
:bottomOffset
v-bind="$attrs"
class="overflow-hidden"
>
<slot/>
</pn-shadow-scroll>
<div
class="absolute-bottom q-px-md q-py-md"
id="card-actions"
v-if="hasActions"
style="z-index: 100;"
>
<q-resize-observer @resize="onActionsResize"/>
<slot name="card-actions"/>
<div style="height: var(--tg-safe-area-inset-bottom)"/>
</div>
<div
class="absolute column q-px-md q-gutter-y-md"
:class="hasActions || tabsSlotHeight ? 'q-pb-none' : 'q-pb-md'"
:style="{ bottom: bottomOffset + 'px', right: '0' }"
v-if="hasFAB"
style="z-index: 100;"
>
<slot name="card-fab"/>
</div>
</div> </div>
<slot/>
<div v-if="hasFooter" class="flex w100 q-pa-md bg-white">
<slot name="footer"/>
</div>
<slot v-if="hasTabs" name="tabs"/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSlots, computed } from 'vue' import { useSlots, computed, ref, onMounted } from 'vue'
const props = withDefaults(defineProps<{
tabsSlotHeight?: number
}>(),
{
tabsSlotHeight: 0,
}
)
const slots = useSlots() const slots = useSlots()
const hasFooter = computed(() => !!slots.footer) const hasHeader = computed(() => !!slots['card-header'])
const hasTabs = computed(() => !!slots.tabs) const hasActions = computed(() => !!slots['card-actions'])
const hasFAB = computed(() => !!slots['card-fab'])
const hasUnderHeader = computed(() => !!slots['card-under-header'])
const cardHeight = ref(0)
const headerHeight = ref(16)
const actionsHeight = ref(0)
interface sizeParams {
height: number,
width: number
}
function onCardResize (size: sizeParams) {
cardHeight.value = size.height
}
function onHeaderResize (size: sizeParams) {
headerHeight.value = size.height
}
function onActionsResize (size: sizeParams) {
actionsHeight.value = size.height
}
const tgInsetBottom = ref(0)
const getTgInsetBottom = () => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--tg-safe-area-inset-bottom')
.trim()
tgInsetBottom.value = parseFloat(value) || 0
}
onMounted(() => getTgInsetBottom())
const bottomOffset = computed(() => {
return Math.max(props.tabsSlotHeight, actionsHeight.value, tgInsetBottom.value)
})
</script> </script>
<style> <style scoped>
</style> </style>

View File

@@ -0,0 +1,19 @@
<template>
<pn-page-wrapper v-bind="$attrs">
<template #title>
<slot name="title"/>
</template>
<pn-page-card v-bind="$attrs">
<template v-for="(_, name) in $slots" v-slot:[name]>
<slot :name="name"/>
</template>
</pn-page-card>
</pn-page-wrapper>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div
id="page-wrapper"
class="w100 relative-position column no-wrap"
style="height: 100vh;"
>
<div
id="tg-area-inset-top"
style="height: var(--tg-safe-area-inset-top)"
/>
<div
id="tg-safe-area"
class="flex justify-center items-center overflow-hidden"
style="height: var(--tg-content-safe-area-inset-top)"
>
<baseLogo text-color="white" :withBackground="false" :animated="false"/>
</div>
<div
class="flex items-center justify-between q-ma-none q-py-none q-px-md text-white text-h6 no-scroll no-wrap w100"
style="min-height: 48px;"
id="page-title"
>
<slot name="title"/>
</div>
<div
class="column w100"
style="flex: 1 1 auto; min-height: 0;"
>
<slot/>
</div>
<div
id="tabs"
v-if="hasTabs"
class="absolute-bottom w100"
>
<slot name="tabs"/>
</div>
</div>
</template>
<script setup lang="ts">
import { useSlots, computed } from 'vue'
import BaseLogo from 'components/BaseLogo.vue'
const slots = useSlots()
const hasTabs = computed(() => !!slots.tabs)
</script>
<style scoped>
</style>

View File

@@ -1,131 +0,0 @@
<template>
<div
id="page-card"
class="w100 flex column glass-card top-rounded-card no-scroll no-wrap"
>
<div
id="card-body-header"
v-if="!hideHeader && hasHeader"
style="flex-shrink: 0; min-height: var(--top-radius);"
>
<q-resize-observer @resize="onHeaderResize"/>
<slot name="card-body-header"/>
</div>
<div
id="card-body"
class="top-rounded-card bg-white column no-wrap"
>
<div style="min-height: var(--top-radius);">
<slot name="card-body-btn"/>
<q-resize-observer @resize="onBodyBtnResize"/>
</div>
<div class="column col col-grow">
<q-resize-observer @resize="onBodyResize"/>
<pn-shadow-scroll
:hideShadows="isResizing"
:height="scrollAreaHeight"
@updateScrollPosition="onScrollUpdate"
:scrollPosition
>
<div
class="w100 column"
:style="{ minHeight: scrollAreaHeight + 'px' }"
>
<slot/>
</div>
</pn-shadow-scroll>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, useSlots, computed } from 'vue'
const emit = defineEmits(['updateScrollPosition', 'updateHeight'])
const onScrollUpdate = (pos: number) => emit('updateScrollPosition', pos)
const slots = useSlots()
// work only slot no use in component
const hasHeader = computed(() => !!slots['card-body-header'])
// need if slot draw by expression with v-if
withDefaults(defineProps<{
hideHeader?: boolean
scrollPosition?: number
}>(), {
hideHeader: false,
scrollPosition: 0
})
const heightCard = ref(100)
const scrollAreaHeight = ref(100)
const headerHeight = ref(0)
const headerBtnHeight = ref(0)
interface sizeParams {
height: number,
width: number
}
async function onHeaderResize(size: sizeParams) {
headerHeight.value = size.height
await updateScrollAreaHeight()
}
function onBodyBtnResize (size: sizeParams) {
headerBtnHeight.value = size.height
}
async function onBodyResize (size: sizeParams) {
heightCard.value = size.height - headerBtnHeight.value
await updateScrollAreaHeight()
}
async function updateScrollAreaHeight() {
await nextTick(() => {
scrollAreaHeight.value = Math.max(0, heightCard.value)
emit('updateHeight', scrollAreaHeight.value)
})
}
watch(heightCard, updateScrollAreaHeight)
watch(headerHeight, updateScrollAreaHeight)
const isResizing = ref(false)
let resizeTimer: ReturnType<typeof setTimeout> | null = null
watch(heightCard, () => {
isResizing.value = true
if (resizeTimer) {
clearTimeout(resizeTimer)
resizeTimer = null
}
resizeTimer = setTimeout(() => {
isResizing.value = false
resizeTimer = null
}, 150)
})
</script>
<style scoped>
#page-card {
flex: 1 0 auto;
min-height: 0;
overflow: hidden;
}
#card-body {
overflow: hidden;
flex: 1 1 0;
min-height: 0;
position: relative;
}
#card-body :deep(.q-scrollarea__content) {
width: 100% !important;
}
</style>

View File

@@ -1,125 +1,107 @@
<template> <template>
<div :class="{'fix-scroll-area-content': hideShadows }" class="fix-scroll-area-content-width column col col-grow"> <q-scroll-area
<q-scroll-area ref="scrollAreaRef"
ref="scrollAreaRef" :style="scrollStyle"
:style="{ height: height + 'px' }" :vertical-offset="[top, bottom]"
class="w100 q-pa-none q-ma-none column col col-grow" :thumb-style="{ borderRadius: '3px', background: '#999', width: '6px', opacity: '0.4' }"
@scroll="onScroll" @scroll="onScroll"
:class=" { class="shadow-scroll w100"
'shadow-top': hasScrolled, >
'shadow-bottom': hasScrolledBottom <q-resize-observer @resize="onInnerResize"/>
}" <div :style="{ height: top + 'px'}"/>
> <slot/>
<slot/> <div :style="{ height: bottom + 'px'}"/>
</q-scroll-area> </q-scroll-area>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onActivated, watch } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps<{ const props = withDefaults(defineProps<{
height: number height: number
hideShadows: boolean topOffset?: number
bottomOffset?: number
scrollPosition?: number scrollPosition?: number
}>() offset?: number
}>(),
{
scrollPosition: 0,
offset: 16
}
)
const emit = defineEmits(['updateScrollPosition']) const emit = defineEmits([
'updateScrollPosition',
'resizeScrollArea'
])
const hasScrolled = ref(false) const top = computed(() => props.topOffset || props.offset)
const hasScrolledBottom = ref(false) const bottom = computed(() => props.bottomOffset || props.offset)
const innerHeight = ref(0)
interface ScrollInfo { const scrollStyle = computed(() => ({
verticalPosition: number; height: props.height + 'px',
verticalPercentage: number; '--top-height': top.value + 'px',
verticalSize: number; '--bottom-height': bottom.value + 'px'
verticalContainerSize: number; }))
}
function onScroll (info: ScrollInfo) { function onScroll () {
hasScrolled.value = info.verticalPosition > 0
const scrollEnd = info.verticalPosition + info.verticalContainerSize >= info.verticalSize - 1
hasScrolledBottom.value = !scrollEnd
emit('updateScrollPosition', scrollAreaRef.value?.getScrollPosition().top || 0) emit('updateScrollPosition', scrollAreaRef.value?.getScrollPosition().top || 0)
} }
const scrollAreaRef = ref() const scrollAreaRef = ref()
onActivated(() => { watch(() => props.scrollPosition, async (newPosition) => {
scrollAreaRef.value?.setScrollPosition('vertical', props?.scrollPosition || 0) await nextTick()
})
watch(() => props.scrollPosition, (newPosition) => {
scrollAreaRef.value?.setScrollPosition('vertical', newPosition) scrollAreaRef.value?.setScrollPosition('vertical', newPosition)
}, { immediate: true }) }, { immediate: true })
watch([top, bottom, () => props.height], async () => {
await nextTick()
emit('resizeScrollArea', {
height: props.height,
innerHeight: innerHeight.value,
topOffset: top.value,
bottomOffset: bottom.value
})
}, { immediate: true })
interface sizeParams {
height: number,
width: number
}
function onInnerResize (size: sizeParams) {
innerHeight.value = size.height
}
</script> </script>
<style scoped> <style scoped>
.q-scrollarea { .shadow-scroll:before {
position: relative; content: "";
transform: translateY(0);
}
.q-scrollarea::before {
content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; width: 100%;
height: 4px; height: var(--top-height);
background: linear-gradient(to bottom, background: linear-gradient(white, transparent);
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
pointer-events: none; pointer-events: none;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); z-index: 95;
opacity: 0;
transform: translateY(-8px);
will-change: opacity, transform;
z-index: 1;
} }
.q-scrollarea::after { .shadow-scroll:after {
content: ''; content: "";
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0;
height: 4px;
background: linear-gradient(to top,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
pointer-events: none;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(8px);
will-change: opacity, transform;
z-index: 1;
}
.fix-scroll-area-content-width:deep(.q-scrollarea__content) {
width: 100%; width: 100%;
} height: var(--bottom-height);
background: linear-gradient(transparent, white);
.fix-scroll-area-content:deep(.q-scrollarea::before) { pointer-events: none;
content: none; z-index: 95;
}
.fix-scroll-area-content:deep(.q-scrollarea::after) {
content: none;
} }
.q-scrollarea.shadow-top::before { .shadow-scroll :deep(.q-scrollarea__content) {
opacity: 1; max-width: min(100vw, var(--body-width));
transform: translateY(0);
}
.q-scrollarea.shadow-bottom::after {
opacity: 1;
transform: translateY(0);
} }
</style> </style>

View File

@@ -1,72 +1,101 @@
<template> <template>
<q-dialog v-model="modelValue" class="q-ma-none"> <q-dialog
v-model="modelValue"
class="q-ma-none"
>
<q-card <q-card
class="q-pa-none q-ma-none w100 no-scroll rounded-card" class="q-pa-none q-ma-none w100 q-pb-md no-scroll rounded-card no-wrap overflow-hidden"
style="max-height: calc(100vh - 48px);" ref="cardRef"
style="
max-height: calc(100vh - 48px - 2 * max(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top), var(--tg-safe-area-inset-bottom)));
width: calc(100vw - 32px);
"
> >
<q-card-section class="row items-center q-pb-none"> <q-resize-observer @resize="onResizeCard" />
<div class="flex no-wrap items-center w100"> <q-card-section class="row items-center q-pb-none" ref="cardHeaderRef">
<q-resize-observer @resize="onHeaderResize"/> <div class="flex no-wrap items-center w100">
<span class="text-bold"> <q-resize-observer @resize="onHeaderResize"/>
{{ $t(title)}} <span class="text-bold">
</span> {{ $t(title)}}
<q-space /> </span>
<q-btn icon="close" flat round dense v-close-popup /> <q-space />
</div> <q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<div>
<pn-shadow-scroll :height="scrollAreaHeight" :hideShadows="false">
<div>
<q-resize-observer @resize="onBodyResize"/>
<slot/>
</div> </div>
</pn-shadow-scroll> </q-card-section>
</div>
<div
ref="cardBodyRef"
class="q-px-none q-ma-none"
>
<pn-shadow-scroll
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-pa-none q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div>
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import pnShadowScroll from './pnShadowScroll.vue' import { useSlots } from 'vue'
defineProps<{
title: string
}>()
const modelValue = defineModel<boolean>({ const modelValue = defineModel<boolean>({
required: true required: true
}) })
defineProps<{
title: string
}>()
const slots = useSlots()
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0) const headerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0) const bodyHeight = ref(0)
const scrollAreaHeight = ref(0) const cardHeight = ref(0)
interface sizeParams { interface sizeParams {
height: number, height: number,
width: number width: number
} }
function onResizeCard (size :sizeParams) {
cardHeight.value = size.width
}
function onHeaderResize (size: sizeParams) { function onHeaderResize (size: sizeParams) {
headerHeight.value = size.height headerHeight.value = size.height
} }
function onBodyResize (size: sizeParams) { const updateDimensions = async () => {
bodyHeight.value = size.height
}
async function updateScrollAreaHeight () {
await nextTick(() => { await nextTick(() => {
scrollAreaHeight.value = Math.min( bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
bodyHeight.value + 16,
window.innerHeight - headerHeight.value - 64 const maxH = cardMaxHeight()
)
const availableForBody = maxH - headerHeight.value - 32 // 16 - md-padding + 16px - shadow-block
bodyHeight.value = Math.min(bodyInnerHeight.value + 32, availableForBody)
}) })
} }
watch(bodyHeight, updateScrollAreaHeight) watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
watch(headerHeight, updateScrollAreaHeight) watch(modelValue, updateDimensions)
const cardRef = ref()
function cardMaxHeight () {
const el = cardRef.value?.$el
if (el) {
const computedStyle = window.getComputedStyle(el)
return parseFloat(computedStyle.maxHeight)
}
return 0
}
</script> </script>

View File

@@ -56,7 +56,7 @@
> >
<div class="flex items-center"> <div class="flex items-center">
<q-icon name="close"/> <q-icon name="close"/>
{{$t('close')}} {{ $t('close') }}
</div> </div>
</q-btn> </q-btn>
</div> </div>

View File

@@ -1,9 +1,9 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t(title) }} {{ $t(title) }}
</template> </template>
<template #footer> <template #card-actions>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100 fix-disabled-btn" class="w100 fix-disabled-btn"
@@ -14,54 +14,65 @@
</q-btn> </q-btn>
</template> </template>
<pn-scroll-list> <div class="flex column items-center q-pa-md q-pb-sm w100">
<div class="flex column items-center q-pa-md q-pb-sm w100"> <pn-image-selector
<pn-image-selector v-model="projectMod.logo"
v-model="projectMod.logo" :size="100"
:size="100" :iconsize="80"
:iconsize="80" type="rounded"
type="rounded" class="q-pb-lg"
class="q-pb-lg" />
<q-form
ref="formRef"
@validation-success="isFormValid = true"
@validation-error="isFormValid = false"
class="q-gutter-y-lg w100"
>
<q-input
v-model.trim="projectMod.name"
no-error-icon
dense
filled
label-slot
class = "w100 fix-bottom-padding"
:rules="[inputRules.require, inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
>
<template #label>
{{ $t('project_block__project_name') }} <span class="text-red">*</span>
</template>
</q-input>
<q-input
v-model="projectMod.description"
dense
filled
autogrow
no-error-icon
class="w100 q-pt-sm fix-bottom-padding"
:label="$t('project_block__project_description')"
:rules="[inputRules.longText]"
@update:model-value="formRef.validate()"
debounce="300"
/> />
<q-form <q-input
ref="formRef" v-model="projectMod.chat_info"
@validation-success="isFormValid = true" dense
@validation-error="isFormValid = false" filled
class="q-gutter-y-lg w100" autogrow
> no-error-icon
<q-input class="w100 q-pt-sm"
v-model.trim="projectMod.name" :label="$t('project_block__project_chat_info')"
no-error-icon :rules="[inputRules.longText]"
dense @update:model-value="formRef.validate()"
filled debounce="300"
label-slot />
class = "w100 fix-bottom-padding" </q-form>
:rules="[inputRules.require, inputRules.middleText]" </div>
@update:model-value="formRef.validate()" </pn-page-template>
debounce="300"
>
<template #label>
{{ $t('project_block__project_name') }} <span class="text-red">*</span>
</template>
</q-input>
<q-input
v-model="projectMod.description"
dense
filled
autogrow
no-error-icon
class="w100 q-pt-sm"
:label="$t('project_block__project_description')"
:rules="[inputRules.longText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
</template> </template>
@@ -84,6 +95,7 @@
const projectMod = reactive({ const projectMod = reactive({
name: props.project?.name ?? '', name: props.project?.name ?? '',
description: props.project?.description ?? '', description: props.project?.description ?? '',
chat_info: props.project?.chat_info ?? '',
logo: props.project?.logo ?? null logo: props.project?.logo ?? null
}) })
@@ -94,6 +106,7 @@
const outData = { const outData = {
name: projectMod.name, name: projectMod.name,
description: getFieldValue(projectMod.description, props.project?.description), description: getFieldValue(projectMod.description, props.project?.description),
chat_info: getFieldValue(projectMod.chat_info, props.project?.chat_info),
logo: projectMod.logo !== '' ? projectMod.logo : null logo: projectMod.logo !== '' ? projectMod.logo : null
} }
emit('update', removeNullKeys(outData, ['logo'])) emit('update', removeNullKeys(outData, ['logo']))

View File

@@ -69,6 +69,7 @@
title="subscribe__dialog_select_tariff_title" title="subscribe__dialog_select_tariff_title"
v-model="showTarfiffSelectDialog" v-model="showTarfiffSelectDialog"
> >
<div class="w100 column"> <div class="w100 column">
<q-banner <q-banner
v-if="!currentTariff.can_change" v-if="!currentTariff.can_change"
@@ -103,12 +104,13 @@
<div <div
class="w100 text-info cursor-pointer" class="w100 text-info cursor-pointer"
@click="router.push({ name: 'change_tariff_rules'})" @click="router.push({ name: 'tariffs_info'})"
> >
{{ $t('subscribe__dialog_select_tariff_notes_more') }} {{ $t('subscribe__dialog_select_tariff_notes_more') }}
</div> </div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div class="column"> <div class="column">
<q-btn <q-btn
@@ -149,7 +151,6 @@
import telegramStar from 'components/TelegramStar.vue' import telegramStar from 'components/TelegramStar.vue'
import pnSubscribeTariffs from 'components/subscribe/pnSubscribeTariffs.vue' import pnSubscribeTariffs from 'components/subscribe/pnSubscribeTariffs.vue'
import pnSubscribePeriods from 'components/subscribe/pnSubscribePeriods.vue' import pnSubscribePeriods from 'components/subscribe/pnSubscribePeriods.vue'
import pnBottomSheetDialog from 'components/pnBottomSheetDialog.vue'
import { import {
calcChangeTariff, calcChangeTariff,
changeTariff, changeTariff,
@@ -176,6 +177,7 @@
const selectedTariffPrice = computed(() => props.tariffs.find((el: Tariff) => el.id === props.currentTariff?.id)?.price ?? 0) const selectedTariffPrice = computed(() => props.tariffs.find((el: Tariff) => el.id === props.currentTariff?.id)?.price ?? 0)
const showTarfiffSelectDialog = ref(false) const showTarfiffSelectDialog = ref(false)
async function clickChangeTariff () { async function clickChangeTariff () {
if (props.currentTariff && if (props.currentTariff &&
selectedTariff.value && selectedTariff.value &&
@@ -196,6 +198,7 @@
description: 'subscribe__dialog_select_tariff_warn_once_day_msg_2' description: 'subscribe__dialog_select_tariff_warn_once_day_msg_2'
} }
} }
return {}
}) })
const activePeriodMessage = ref('') const activePeriodMessage = ref('')
@@ -218,7 +221,7 @@
const renew = await calcRenewTariff(props.currentTariff.id, selectedPeriod.value) const renew = await calcRenewTariff(props.currentTariff.id, selectedPeriod.value)
const textBtn = t('subscribe__pay_renew') + ' ⭐' + formatNumber(renew.amount) + const textBtn = t('subscribe__pay_renew') + ' ⭐' + formatNumber(renew.amount) +
' (' + t('subscribe__tariff_exp') + ' ' + date.formatDate(renew.end_date * 1000, 'DD.MM.YYYY') + ')' ' (' + t('subscribe__tariff_exp') + ' ' + date.formatDate(renew.end_date * 1000, 'DD.MM.YYYY') + ')'
const url = await urlRenewTariff(props.currentTariff.id, selectedPeriod.value) const url = urlRenewTariff(props.currentTariff.id, selectedPeriod.value)
emit('update', { status: 'show', textBtn, url }) emit('update', { status: 'show', textBtn, url })
} }

View File

@@ -1,9 +1,9 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t(title) }} {{ $t(title) }}
</template> </template>
<template #footer> <template #card-actions>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100 fix-disabled-btn" class="w100 fix-disabled-btn"
@@ -13,143 +13,142 @@
{{ $t(btnText) }} {{ $t(btnText) }}
</q-btn> </q-btn>
</template> </template>
<pn-scroll-list>
<div class="column items-center q-pa-md q-pb-sm">
<div class="relative-position flex justify-center w100 text-center">
<pn-auto-avatar
:img="userMod.photo"
:name="tname"
size="100px"
class="q-pb-lg"
:style="!userStatus ? {} : { filter: 'grayscale(100%)'}"
/>
<div
v-if="userStatus"
class="flex justify-center absolute-center w100 items-center"
>
<q-chip
square
:label="$t(userStatus.text)"
:color="userStatus.color"
text-color="white"
class="text-uppercase"
/>
</div>
</div>
<div class="flex row items-start justify-center no-wrap q-pb-lg">
<div class="flex column justify-center">
<div class="text-bold q-pr-xs text-center" align="center">{{ tname }}</div>
<div caption class="text-primary text-caption" align="center" v-if="user?.username">{{ user.username }}</div>
</div>
</div>
<q-form <div class="column items-center q-pa-md q-pb-sm">
ref="formRef" <div class="relative-position flex justify-center w100 text-center">
@validation-success="isFormValid = true" <pn-auto-avatar
@validation-error="isFormValid = false" :img="userMod.photo"
class="q-gutter-y-lg w100" :name="tname"
size="100px"
class="q-pb-lg"
:style="!userStatus ? {} : { filter: 'grayscale(100%)'}"
/>
<div
v-if="userStatus"
class="flex justify-center absolute-center w100 items-center"
> >
<q-input <q-chip
v-model.trim="userMod.fullname" square
dense :label="$t(userStatus.text)"
filled :color="userStatus.color"
class = "w100 fix-bottom-padding" text-color="white"
:label = "$t('user_block__name')" class="text-uppercase"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/> />
</div>
<q-select
v-model="userMod.company_id"
:options="displayCompanies"
dense
filled
class="w100 q-pt-sm"
:label = "$t('user_block__company')"
option-value="id"
emit-value
map-options
@update:model-value="formRef.validate()"
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<pn-auto-avatar
v-if="scope.opt.id"
:img="scope.opt.logo"
:name="scope.opt.label"
size="md"
type="rounded"
/>
<q-avatar
v-else
rounded
size="md"
>
<q-icon size="32px" color="grey" name="mdi-cancel"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
v-model.trim="userMod.department"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:label = "$t('user_block__department')"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
<q-input
v-model.trim="userMod.role"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:label = "$t('user_block__role')"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
<q-input
v-model.trim="userMod.phone"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
>
<template #prepend>
<q-icon name="mdi-phone-outline"/>
</template>
</q-input>
<q-input
v-model.trim="userMod.email"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
>
<template #prepend>
<q-icon name="mdi-email-outline"/>
</template>
</q-input>
</q-form>
</div> </div>
</pn-scroll-list> <div class="flex row items-start justify-center no-wrap q-pb-lg">
</pn-page-card> <div class="flex column justify-center">
<div class="text-bold q-pr-xs text-center" align="center">{{ tname }}</div>
<div caption class="text-primary text-caption" align="center" v-if="user?.username">{{ user.username }}</div>
</div>
</div>
<q-form
ref="formRef"
@validation-success="isFormValid = true"
@validation-error="isFormValid = false"
class="q-gutter-y-lg w100"
>
<q-input
v-model.trim="userMod.fullname"
dense
filled
class = "w100 fix-bottom-padding"
:label = "$t('user_block__name')"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
<q-select
v-model="userMod.company_id"
:options="displayCompanies"
dense
filled
class="w100 q-pt-sm"
:label = "$t('user_block__company')"
option-value="id"
emit-value
map-options
@update:model-value="formRef.validate()"
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<pn-auto-avatar
v-if="scope.opt.id"
:img="scope.opt.logo"
:name="scope.opt.label"
size="md"
type="rounded"
/>
<q-avatar
v-else
rounded
size="md"
>
<q-icon size="32px" color="grey" name="mdi-cancel"/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
v-model.trim="userMod.department"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:label = "$t('user_block__department')"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
<q-input
v-model.trim="userMod.role"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:label = "$t('user_block__role')"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
<q-input
v-model.trim="userMod.phone"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
>
<template #prepend>
<q-icon name="mdi-phone-outline"/>
</template>
</q-input>
<q-input
v-model.trim="userMod.email"
dense
filled
class = "w100 fix-bottom-padding q-pt-sm"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
>
<template #prepend>
<q-icon name="mdi-email-outline"/>
</template>
</q-input>
</q-form>
</div>
</pn-page-template>
</template> </template>

View File

@@ -68,10 +68,6 @@ body {
margin-right: calc(max((100vw - var(--body-width))/2, 0px) + 18px) !important; margin-right: calc(max((100vw - var(--body-width))/2, 0px) + 18px) !important;
} }
.projects-header {
background-color: #eee;
}
.top-rounded-card { .top-rounded-card {
border-top-left-radius: var(--top-radius) !important; border-top-left-radius: var(--top-radius) !important;
border-top-right-radius: var(--top-radius) !important; border-top-right-radius: var(--top-radius) !important;
@@ -81,8 +77,9 @@ body {
border-radius: var(--top-radius) !important; border-radius: var(--top-radius) !important;
} }
.glass-card { .rounded-fix {
background-color: rgb(255, 255, 255, 0.91) !important; border-radius: 1000px;
overflow: hidden;
} }
.orline { .orline {
@@ -105,6 +102,7 @@ body {
.fix-disabled-btn.q-btn[disabled]:not(.q-btn--flat) { .fix-disabled-btn.q-btn[disabled]:not(.q-btn--flat) {
background-color: $grey-5 !important; background-color: $grey-5 !important;
opacity: 1 !important;
} }
.fix-disabled-btn.q-btn.q-btn--flat[disabled] { .fix-disabled-btn.q-btn.q-btn--flat[disabled] {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<q-page-container <q-page-container
class="main-content q-pa-none q-ma-none no-scroll bg-transparent" class="main-content q-pa-none q-ma-none no-scroll bg-transparent"
> >
<q-page class="no-scroll column"> <q-page>
<router-view /> <router-view />
</q-page> </q-page>
</q-page-container> </q-page-container>

View File

@@ -1,12 +1,10 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('account__change_auth_method')}} {{$t('account__change_auth_method')}}
</template> </template>
<pn-scroll-list> <account-helper :type/>
<account-helper :type/> </pn-page-template>
</pn-scroll-list>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,192 +1,191 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('account_change_email__title')}} {{$t('account_change_email__title')}}
</template> </template>
<pn-scroll-list>
<q-stepper <q-stepper
v-model="step" v-model="step"
vertical vertical
color="primary" color="primary"
animated animated
flat flat
class="bg-transparent" class="bg-transparent"
>
<q-step
:name="1"
:title="$t('account_change_email__current_email')"
:done="step > 1"
> >
<q-step <q-input
:name="1" v-model="login"
:title="$t('account_change_email__current_email')" autofocus
:done="step > 1" dense
> filled
<q-input :label = "$t('account_change_email__current_email')"
v-model="login" disable
autofocus />
dense <q-stepper-navigation>
filled <q-btn
:label = "$t('account_change_email__current_email')" @click="handleSubmit"
disable color="primary"
:label="$t('continue')"
/> />
<q-stepper-navigation> </q-stepper-navigation>
<q-btn </q-step>
@click="handleSubmit"
color="primary"
:label="$t('continue')"
/>
</q-stepper-navigation>
</q-step>
<q-step <q-step
:name="2" :name="2"
:title="$t('account_change_email__confirm_current_email')" :title="$t('account_change_email__confirm_current_email')"
:done="step > 2" :done="step > 2"
> >
<div class="q-pb-md">{{$t('account_change_email__confirm_email_message')}}</div> <div class="q-pb-md">{{$t('account_change_email__confirm_email_message')}}</div>
<q-input <q-input
v-model="code" v-model="code"
dense dense
filled filled
autofocus autofocus
hide-bottom-space hide-bottom-space
:label = "$t('account_change_email__code')" :label = "$t('account_change_email__code')"
num="30" num="30"
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disable="code.length === 0"
/> />
<q-stepper-navigation> <q-btn
<q-btn flat
@click="handleSubmit" @click="step = 1"
color="primary" color="primary"
:label="$t('continue')" :label="$t('back')"
:disable="code.length === 0" class="q-ml-sm"
/>
<q-btn
flat
@click="step = 1"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="3"
:title="$t('account_change_email__new_email')"
:done="step > 2"
>
<q-input
v-model="newLogin"
autofocus
dense
filled
:label = "$t('account_change_email__new_email')"
:rules="validationRules.email"
lazy-rules
no-error-icon
@focus="($refs.emailInput as typeof QInput)?.resetValidation()"
ref="emailInput"
/> />
<q-stepper-navigation> </q-stepper-navigation>
<q-btn </q-step>
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disabled="!isEmailValid"
/>
<q-btn
flat
@click="step = 2"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step <q-step
:name="4" :name="3"
:title="$t('account_change_email__confirm_new_email')" :title="$t('account_change_email__new_email')"
:done="step > 3" :done="step > 2"
> >
<div class="q-pb-md">{{$t('account_change_email__confirm_email_message')}}</div> <q-input
<q-input v-model="newLogin"
v-model="newCode" autofocus
dense dense
filled filled
autofocus :label = "$t('account_change_email__new_email')"
hide-bottom-space :rules="validationRules.email"
:label = "$t('account_change_email__code')" lazy-rules
num="30" no-error-icon
@focus="($refs.emailInput as typeof QInput)?.resetValidation()"
ref="emailInput"
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disabled="!isEmailValid"
/> />
<q-stepper-navigation> <q-btn
<q-btn flat
@click="handleSubmit" @click="step = 2"
color="primary" color="primary"
:label="$t('continue')" :label="$t('back')"
:disable="newCode.length === 0" class="q-ml-sm"
/> />
<q-btn </q-stepper-navigation>
flat </q-step>
@click="step = 3"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step <q-step
:name="5" :name="4"
:title="$t('account_change_email__set_password')" :title="$t('account_change_email__confirm_new_email')"
:done="step > 3"
>
<div class="q-pb-md">{{$t('account_change_email__confirm_email_message')}}</div>
<q-input
v-model="newCode"
dense
filled
autofocus
hide-bottom-space
:label = "$t('account_change_email__code')"
num="30"
/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('continue')"
:disable="newCode.length === 0"
/>
<q-btn
flat
@click="step = 3"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="5"
:title="$t('account_change_email__set_password')"
>
<q-input
v-model="password"
dense
filled
:label = "$t('account_change_email__password')"
:type="isPwd ? 'password' : 'text'"
hide-hint
:hint="passwordHint"
:rules="validationRules.password"
lazy-rules
no-error-icon
@focus="($refs.passwordInput as typeof QInput)?.resetValidation()"
ref="passwordInput"
> >
<q-input <template #append>
v-model="password" <q-icon
dense color="grey-5"
filled :name="isPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:label = "$t('account_change_email__password')" class="cursor-pointer"
:type="isPwd ? 'password' : 'text'" @click="isPwd = !isPwd"
hide-hint />
:hint="passwordHint" </template>
:rules="validationRules.password" </q-input>
lazy-rules
no-error-icon
@focus="($refs.passwordInput as typeof QInput)?.resetValidation()"
ref="passwordInput"
>
<template #append>
<q-icon
color="grey-5"
:name="isPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<q-stepper-navigation> <q-stepper-navigation>
<q-btn <q-btn
@click="handleSubmit" @click="handleSubmit"
color="primary" color="primary"
:label="$t('account_change_email__finish')" :label="$t('account_change_email__finish')"
:disabled = "!isPasswordValid" :disabled = "!isPasswordValid"
/> />
<q-btn <q-btn
flat flat
@click="step = 4" @click="step = 4"
color="primary" color="primary"
:label="$t('back')" :label="$t('back')"
class="q-ml-sm" class="q-ml-sm"
/> />
</q-stepper-navigation> </q-stepper-navigation>
</q-step> </q-step>
</q-stepper> </q-stepper>
<pn-magic-overlay <pn-magic-overlay
v-if="showSuccessOverlay" v-if="showSuccessOverlay"
icon="mdi-check-circle-outline" icon="mdi-check-circle-outline"
message1="account_change_email__ok_message1" message1="account_change_email__ok_message1"
message2="account_change_email__ok_message2" message2="account_change_email__ok_message2"
route-name="account" route-name="account"
/> />
</pn-scroll-list> </pn-page-template>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,12 +1,10 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('account__change_password')}} {{$t('account__change_password')}}
</template> </template>
<pn-scroll-list> <account-helper :type :email/>
<account-helper :type :email/> </pn-page-template>
</pn-scroll-list>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,12 +1,10 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('login__register_title')}} {{$t('login__register_title')}}
</template> </template>
<pn-scroll-list> <account-helper :type :email/>
<account-helper :type :email/> </pn-page-template>
</pn-scroll-list>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,12 +1,10 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('forgot_password__password_recovery')}} {{$t('forgot_password__password_recovery')}}
</template> </template>
<pn-scroll-list> <account-helper :type :email/>
<account-helper :type :email/> </pn-page-template>
</pn-scroll-list>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,5 +1,5 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
<pn-account-block-name/> <pn-account-block-name/>
<q-btn <q-btn
@@ -10,38 +10,35 @@
/> />
</template> </template>
<pn-scroll-list> <q-list>
<q-list separator> <q-item
<q-item v-for="item in displayItems"
v-for="item in displayItems" :key="item.id"
:key="item.id" @click="goTo(item.pathName)"
@click="goTo(item.pathName)" clickable
clickable v-ripple
v-ripple >
> <q-item-section avatar>
<q-item-section avatar> <q-avatar
<q-avatar :icon="item.icon"
:icon="item.icon" :color="item.iconColor ? item.iconColor: 'brand'"
:color="item.iconColor ? item.iconColor: 'brand'" text-color="white"
text-color="white" rounded
rounded font-size ="26px"
font-size ="26px" />
/> </q-item-section>
</q-item-section> <q-item-section>
<q-item-section> <q-item-label>
<q-item-label> {{ $t(item.name) }}
{{ $t(item.name) }} </q-item-label>
</q-item-label> <q-item-label class="text-caption" v-if="$te(item.description)">
<q-item-label class="text-caption" v-if="$te(item.description)"> {{ $t(item.description) }}
{{ $t(item.description) }} </q-item-label>
</q-item-label> </q-item-section>
</q-item-section> </q-item>
</q-item> </q-list>
</q-list>
</pn-scroll-list>
</pn-page-card>
<pn-small-dialog <pn-small-dialog
v-model="showStopUsingDialog" v-model="showStopUsingDialog"
icon="mdi-close-box-outline" icon="mdi-close-box-outline"
color="negative" color="negative"
@@ -51,6 +48,7 @@
mainBtnLabel="account__stop_using_dialog_btn_ok" mainBtnLabel="account__stop_using_dialog_btn_ok"
@clickMainBtn="onConfirmStopUsing" @clickMainBtn="onConfirmStopUsing"
/> />
</pn-page-template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -84,7 +82,9 @@
{ id: 7, name: 'account__support', icon: 'mdi-lifebuoy', description: 'account__support_description', iconColor: 'info', pathName: 'support' }, { id: 7, name: 'account__support', icon: 'mdi-lifebuoy', description: 'account__support_description', iconColor: 'info', pathName: 'support' },
{ id: 8, name: 'account__terms_of_use', icon: 'mdi-book-open-variant-outline', description: '', iconColor: 'grey', pathName: 'terms' }, { id: 8, name: 'account__terms_of_use', icon: 'mdi-book-open-variant-outline', description: '', iconColor: 'grey', pathName: 'terms' },
{ id: 9, name: 'account__privacy', icon: 'mdi-lock-outline', description: '', iconColor: 'grey', pathName: 'privacy' }, { id: 9, name: 'account__privacy', icon: 'mdi-lock-outline', description: '', iconColor: 'grey', pathName: 'privacy' },
{ id: 10, name: 'account__stop_using', icon: 'mdi-close-box-outline', description: 'account__stop_using_description', iconColor: 'negative', pathName: 'stop_using' } { id: 10, name: 'account__consent_pd', icon: 'mdi-account-lock-outline', description: '', iconColor: 'grey', pathName: 'consent' },
{ id: 11, name: 'account__tarrifs_info', icon: 'mdi-star-circle-outline', description: '', iconColor: 'grey', pathName: 'tarrifs_info' },
{ id: 12, name: 'account__stop_using', icon: 'mdi-close-box-outline', description: 'account__stop_using_description', iconColor: 'negative', pathName: 'stop_using' }
])) ]))
const displayItems = computed(() => ( const displayItems = computed(() => (

View File

@@ -1,20 +1,18 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('agreements__title')}} {{$t('agreements__title')}}
</template> </template>
<pn-scroll-list>
<div class="flex column w100 q-py-md q-px-lg q-gutter-y-sm text-caption"> <div class="flex column w100 q-py-md q-px-lg q-gutter-y-sm text-caption">
<div class="text-body1"> <div class="text-body1">
<div v-if="route.query.type === 'update'">{{ $t('agreements__description_update') }}</div> <div v-if="route.query.type === 'update'">{{ $t('agreements__description_update') }}</div>
<div>{{ $t('agreements__description') }}</div> <div>{{ $t('agreements__description') }}</div>
</div>
<agreement-block
v-model="agreement"
/>
</div> </div>
</pn-scroll-list> <agreement-block v-model="agreement"/>
<template #footer> </div>
<template #card-actions>
<div class="column no-wrap items-center w100"> <div class="column no-wrap items-center w100">
<q-btn <q-btn
color="primary" color="primary"
@@ -42,7 +40,7 @@
</div> </div>
</template> </template>
</pn-page-card> </pn-page-template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,9 +1,10 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t('chat_card__title') }} {{ $t('chat_card__title') }}
</template> </template>
<template #footer>
<template #card-actions>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100" class="w100"
@@ -14,65 +15,63 @@
</q-btn> </q-btn>
</template> </template>
<pn-scroll-list> <div
v-if="chat"
class="flex column items-center q-px-md q-pt-md q-pb-sm"
>
<pn-auto-avatar
:img="chat.logo"
:name="chat.name"
size="100px"
rounded
/>
<div <div
v-if="chat" class="flex row items-start justify-center q-pt-md text-bold text-center"
class="flex column items-center q-px-md q-pt-md q-pb-sm"
> >
<pn-auto-avatar {{ chat.name }}
:img="chat.logo"
:name="chat.name"
size="100px"
rounded
/>
<div
class="flex row items-start justify-center q-pt-md text-bold text-center"
>
{{ chat.name }}
</div>
<div
v-if="chat.description"
class="flex row items-start justify-center text-caption text-center text-grey"
>
{{ chat.description }}
</div>
</div> </div>
<div class="q-pb-md w100" v-if="chat"> <div
<pn-chat-type-item v-if="chat.description"
:access="chat.bot_access" class="flex row items-start justify-center text-caption text-center text-grey"
:time="chat.bot_access_date" >
/> {{ chat.description }}
<pn-chat-type-item
v-if="chat.state !== 1"
:state="chat.state"
:time="chat.state_date ?? 0"
/>
</div> </div>
</div>
<div class="q-pb-md w100" v-if="chat">
<pn-chat-type-item
:access="chat.bot_access"
:time="chat.bot_access_date"
/>
<pn-chat-type-item
v-if="chat.state !== 1"
:state="chat.state"
:time="chat.state_date ?? 0"
/>
</div>
<q-list separator v-if="chatUsers.length !== 0"> <q-list v-if="chatUsers.length !== 0">
<q-item-label class="text-caption text-bold q-py-none" header> <q-item-label class="text-caption text-bold q-py-none" header>
{{ $t('chat_card__members') + ' (' + chatUsers.length +')' }} {{ $t('chat_card__members') + ' (' + chatUsers.length +')' }}
</q-item-label> </q-item-label>
<q-item <q-item
v-for="item in chatUsers" v-for="item in chatUsers"
:key="item.id" :key="item.id"
v-ripple v-ripple
clickable clickable
class="w100" class="w100"
@click="goUserInfo(item.id)" @click="goUserInfo(item.id)"
> >
<pn-user-list-item-body :item showStatus> <pn-user-list-item-body :item showStatus>
<q-item-label class="text-caption text-amber-10" lines="1" v-if="chat?.owner_id === item.id"> <q-item-label class="text-caption text-amber-10" lines="1" v-if="chat?.owner_id === item.id">
<div class="flex items-center"> <div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/> <q-icon name="star" class="q-pr-xs"/>
{{ $t('chat_card__owner') }} {{ $t('chat_card__owner') }}
</div> </div>
</q-item-label> </q-item-label>
</pn-user-list-item-body> </pn-user-list-item-body>
</q-item> </q-item>
</q-list> </q-list>
</pn-scroll-list> </pn-page-template>
</pn-page-card>
</template> </template>

View File

@@ -1,10 +1,10 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('mask__title')}} {{$t('mask__title')}}
</template> </template>
<template #footer> <template #card-actions>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100 fix-disabled-btn" class="w100 fix-disabled-btn"
@@ -14,131 +14,130 @@
</q-btn> </q-btn>
</template> </template>
<pn-scroll-list> <template #card-header>
<template #card-body-header> <div class="q-pa-md flex no-wrap items-center bg-grey-2">
<div class="q-ma-md flex no-wrap items-center"> <q-icon name="mdi-information q-pr-sm" size="sm" color="primary"/>
<q-icon name="mdi-information q-pr-sm" size="sm" color="primary"/> <span class="text-caption">
<span class="text-caption"> {{ $t('mask__info_block')}}
{{ $t('mask__info_block')}} </span>
</span> </div>
</div> </template>
</template>
<q-table
flat
:rows="displayCompanies"
:columns="tableColumns"
hide-pagination
:pagination="{ rowsPerPage: 0 }"
dense
row-key="id"
style="max-width: 100% !important"
>
<template #header>
<q-tr>
<q-th style="width: 10%">
<q-icon name="mdi-domino-mask" size="sm"/>
</q-th>
<q-th style="width: 45%">
<span class="text-bold">
{{ $t('mask__table_header_company') }}
</span>
</q-th>
<q-th style="width: 45%">
<span class="text-bold">
{{ $t('mask__table_header_visible') }}
<q-btn
flat
@click="showDialogHelp=true"
color="primary"
dense round
size="sm"
icon="mdi-help-circle-outline"
/>
</span>
</q-th>
</q-tr>
</template>
<template #body="props"> <q-table
<q-tr :props="props"> flat
<q-td key="checkbox" :props="props"> :rows="displayCompanies"
<q-toggle :columns="tableColumns"
v-model="props.row.masked" hide-pagination
:pagination="{ rowsPerPage: 0 }"
dense
row-key="id"
style="max-width: 100% !important"
>
<template #header>
<q-tr>
<q-th style="width: 10%">
<q-icon name="mdi-domino-mask" size="sm"/>
</q-th>
<q-th style="width: 45%">
<span class="text-bold">
{{ $t('mask__table_header_company') }}
</span>
</q-th>
<q-th style="width: 45%">
<span class="text-bold">
{{ $t('mask__table_header_visible') }}
<q-btn
flat
@click="showDialogHelp=true"
color="primary"
dense round
size="sm" size="sm"
:color="props.row.masked ? 'red' : 'brand'" icon="mdi-help-circle-outline"
/> />
</q-td> </span>
<q-td key="name" :props="props"> </q-th>
{{ props.row.name }} </q-tr>
</q-td> </template>
<q-td key="visible" :props="props">
<q-select <template #body="props">
v-if="props.row.masked" <q-tr :props="props">
v-model="props.row.company_list" <q-td key="checkbox" :props="props">
multiple <q-toggle
:options="companiesSelect(props.row.id)" v-model="props.row.masked"
option-value="id" size="sm"
emit-value :color="props.row.masked ? 'red' : 'brand'"
dense />
borderless </q-td>
dropdown-icon="mdi-plus" <q-td key="name" :props="props">
class="fix-select" {{ props.row.name }}
:label-slot="!props.row.hasFocus && !props.row.isMenuOpen && (props.row.company_list.length === 0)" </q-td>
@popup-show="props.row.isMenuOpen = true" <q-td key="visible" :props="props">
@popup-hide="props.row.isMenuOpen = false" <q-select
@focus="props.row.hasFocus = true" v-if="props.row.masked"
@blur="props.row.hasFocus = false" v-model="props.row.company_list"
> multiple
<template #label> :options="companiesSelect(props.row.id)"
<span option-value="id"
v-if="(!props.row.hasFocus || !props.row.isMenuOpen) && (props.row.company_list.length === 0)" emit-value
class="q-field__label" dense
> borderless
{{ $t('mask__table_visible_none') }} dropdown-icon="mdi-plus"
</span> class="fix-select"
:label-slot="!props.row.hasFocus && !props.row.isMenuOpen && (props.row.company_list.length === 0)"
@popup-show="props.row.isMenuOpen = true"
@popup-hide="props.row.isMenuOpen = false"
@focus="props.row.hasFocus = true"
@blur="props.row.hasFocus = false"
>
<template #label>
<span
v-if="(!props.row.hasFocus || !props.row.isMenuOpen) && (props.row.company_list.length === 0)"
class="q-field__label"
>
{{ $t('mask__table_visible_none') }}
</span>
</template>
<template #selected>
<div
v-for="id in props.row.company_list"
:key=id
class="q-pa-xs"
>
<pn-auto-avatar
:img="getCompanyById(id)?.logo"
:name="getCompanyById(id)?.name"
size="md"
type="rounded"
/>
</div>
</template> </template>
<template #selected> <template #option="scope">
<div <q-item v-bind="scope.itemProps">
v-for="id in props.row.company_list" <q-item-section avatar>
:key=id
class="q-pa-xs"
>
<pn-auto-avatar <pn-auto-avatar
:img="getCompanyById(id)?.logo" :img="scope.opt.logo"
:name="getCompanyById(id)?.name" :name="scope.opt.name"
size="md" size="md"
type="rounded" type="rounded"
/> />
</div> </q-item-section>
</template> <q-item-section>
<template #option="scope"> <q-item-label>{{ scope.opt.name }}</q-item-label>
<q-item v-bind="scope.itemProps"> </q-item-section>
<q-item-section avatar> </q-item>
<pn-auto-avatar </template>
:img="scope.opt.logo" </q-select>
:name="scope.opt.name" <span
size="md" v-else
type="rounded" >
/> {{ $t('mask__table_visible_all') }}
</q-item-section> </span>
<q-item-section> </q-td>
<q-item-label>{{ scope.opt.name }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<span
v-else
>
{{ $t('mask__table_visible_all') }}
</span>
</q-td>
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>
<div class="q-py-none flex column w100" style="min-height: 18px"/> <div class="q-py-none flex column w100" style="min-height: 18px"/>
</pn-scroll-list>
<q-dialog v-model="showDialogHelp"> <q-dialog v-model="showDialogHelp">
<q-card class="q-ma-sm w100 rounded-card"> <q-card class="q-ma-sm w100 rounded-card">
@@ -156,7 +155,7 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
</pn-page-card> </pn-page-template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -5,129 +5,132 @@
<q-scroll-area <q-scroll-area
style="height: 100vh" style="height: 100vh"
> >
<div class="flex items-center justify-center" style="height: 100vh">
<q-card
id="login_block"
flat
class="flex column no-wrap items-center justify-between q-px-xl q-py-md login-card"
style="max-width: 400px;"
>
<base-logo
class="col-grow q-pa-lg text-h5"
/>
<div class="flex column w100">
<q-input
v-model.trim="login"
dense
filled
class="q-mb-sm"
:label="$t('login__email')"
:rules="validationRules.email"
lazy-rules="ondemand"
no-error-icon
@focus="emailInput?.resetValidation()"
@blur="delayValidity('login')"
ref="emailInput"
/>
<q-input
v-model.trim="password"
dense
filled
:label="$t('login__password')"
class="q-mb-none q-mt-xs"
:type="isPwd ? 'password' : 'text'"
hide-hint
:hint="passwordHint"
:rules="validationRules.password"
lazy-rules="ondemand"
no-error-icon
@focus="passwordInput?.resetValidation()"
@blur="delayValidity('password')"
ref="passwordInput"
>
<template #append>
<q-icon
color="grey-5"
:name="isPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<div class="self-end">
<q-btn
@click.prevent="forgotPwd"
flat no-caps
dense rounded
class="text-grey"
>
<span class="q-px-sm">
{{ $t('login__forgot_password') }}
</span>
</q-btn>
</div>
</div>
<q-btn
@click="sendAuth()"
color="primary"
class="w100 q-my-md fix-disabled-btn q-pt-sm"
:disabled="!isEmailValid || !isPasswordValid"
:unelevated="!isEmailValid || !isPasswordValid"
rounded
>
{{ $t('login__sign_in') }}
</q-btn>
<q-btn
flat rounded
sm no-caps
class="q-my-md w100"
color="primary"
@click="createAccount"
>
{{ $t('login__register') }}
</q-btn>
<div <div
v-if="isTelegramApp" class="w100 flex justify-center items-center"
id="alt_login" style="min-height: 100vh;"
class="flex w100 column items-center "
> >
<div class="orline w100 text-grey"> <pn-glass
<span class="q-mx-sm text-caption"> id="login_block"
{{ $t('login__or_continue_as') }} class="rounded-card flex items-center q-pa-lg q-my-md shadow-1"
</span> style="max-width: 300px;"
</div>
<q-btn
flat rounded
sm no-caps
color="primary"
class="q-my-md w100"
@click="handleTelegramLogin"
> >
<div class="flex items-center text-primary"> <div class="flex column no-wrap items-center">
<q-avatar size="md" class="q-mr-sm"> <div class="q-pb-lg">
<q-img v-if="tgUser?.photo_url" :src="tgUser.photo_url"/> <base-logo
<q-icon v-else size="md" class="q-mr-none" name="telegram"/> withBackground
</q-avatar> animated
class="text-h5"
/>
</div>
<span> <div class="flex column w100">
{{ <q-input
tgUser?.first_name + v-model.trim="login"
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') + dense
tgUser?.last_name + filled
(!(tgUser?.first_name || tgUser?.last_name) ? tgUser?.username : '') class="q-mb-sm"
}} :label="$t('login__email')"
</span> :rules="validationRules.email"
lazy-rules="ondemand"
no-error-icon
@focus="emailInput?.resetValidation()"
@blur="delayValidity('login')"
ref="emailInput"
/>
<q-input
v-model.trim="password"
dense
filled
:label="$t('login__password')"
class="q-mb-none q-mt-xs"
:type="isPwd ? 'password' : 'text'"
hide-hint
:hint="passwordHint"
:rules="validationRules.password"
lazy-rules="ondemand"
no-error-icon
@focus="passwordInput?.resetValidation()"
@blur="delayValidity('password')"
ref="passwordInput"
>
<template #append>
<q-icon
color="grey-5"
:name="isPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
class="cursor-pointer"
@click="isPwd = !isPwd"
/>
</template>
</q-input>
<div class="self-end">
<q-btn
@click.prevent="forgotPwd"
flat no-caps
dense rounded
class="text-grey"
>
<span class="q-px-sm">
{{ $t('login__forgot_password') }}
</span>
</q-btn>
</div>
</div>
<q-btn
@click="sendAuth()"
color="primary"
class="w100 q-my-md fix-disabled-btn q-pt-sm"
:disabled="!isEmailValid || !isPasswordValid"
:unelevated="!isEmailValid || !isPasswordValid"
rounded
>
{{ $t('login__sign_in') }}
</q-btn>
<q-btn
flat rounded
sm no-caps
class="q-my-md w100"
color="primary"
@click="createAccount"
>
{{ $t('login__register') }}
</q-btn>
<div
v-if="isTelegramApp"
id="alt_login"
class="flex w100 column items-center "
>
<div class="orline w100 text-grey">
<span class="q-mx-sm text-caption">
{{ $t('login__or_continue_as') }}
</span>
</div>
<q-btn
flat rounded
sm no-caps
color="primary"
class="q-mt-md w100"
@click="handleTelegramLogin"
>
<div class="flex items-center text-primary no-wrap w100 justify-center">
<q-avatar size="md" class="q-mr-sm">
<q-img v-if="tgUser?.photo_url" :src="tgUser.photo_url"/>
<q-icon v-else size="md" class="q-mr-none" name="telegram"/>
</q-avatar>
<span class="ellipsis">
{{ nameTgUser(tgUser) }}
</span>
</div>
</q-btn>
</div>
</div> </div>
</q-btn> </pn-glass>
</div> </div>
</q-card>
</div>
</q-scroll-area> </q-scroll-area>
</q-page> </q-page>
</template> </template>
@@ -138,11 +141,13 @@
import baseLogo from 'components/BaseLogo.vue' import baseLogo from 'components/BaseLogo.vue'
import { useInputRules } from 'composables/useInputRules' import { useInputRules } from 'composables/useInputRules'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import type { WebApp } from '@twa-dev/types'
import { QInput } from 'quasar' import { QInput } from 'quasar'
import pnGlass from 'components/pnGlass.vue'
import { nameTgUser } from 'utils/helpers'
import type { WebApp, WebAppUser } from '@twa-dev/types'
const tg = inject('tg') as WebApp const tg = inject('tg') as WebApp
const tgUser = tg.initDataUnsafe.user const tgUser = tg.initDataUnsafe.user as WebAppUser | undefined
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()

View File

@@ -1,124 +1,203 @@
<template> <template>
<pn-page-card> <pn-page-wrapper>
<template #title> <template #title>
<project-page-header/> <project-page-header/>
</template> </template>
<q-tab-panels <q-tab-panels
id="tab-panels"
v-model="tabSelect" v-model="tabSelect"
keep-alive keep-alive
class="tab-panel-color full-height-panel w100 flex column col-grow no-scroll" class="bg-transparent w100"
style="flex-grow: 1;"
> >
<q-tab-panel <q-tab-panel
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.name" :key="tab.name"
:name="tab.name" :name="tab.name"
class="q-pa-none flex column col-grow no-scroll" class="q-pa-none column w100"
style="flex-grow: 2"
> >
<router-view/> <router-view :tabsSlotHeight/>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
<template #tabs> <template #tabs>
<q-footer class="bg-grey-1 text-grey main-content"> <div class="q-pa-none q-ma-none">
<q-tabs <q-resize-observer @resize="onTabsSlotResize"/>
style = "z-index: 1000" <div class="q-py-md q-px-md overflow-hidden rounded-fix">
v-model="tabSelect" <div style="height: 0px;" class="row no-wrap no-pointer-events overflow-hidden">
dense <div
align="justify" v-for="(tab, idx) in tabs"
id="tabs" :key="idx"
> class="text-caption"
<q-route-tab >
v-for="tab in tabs" <q-resize-observer @resize="val => onTabResize(val, idx)"/>
:to="tab.to" {{ $t(tab.label) }}
:name="tab.name" </div>
:key="tab.name" </div>
no-caps
<q-tabs
v-model="tabSelect"
dense dense
:class="tabSelect === tab.name ? 'active' : ''" align="center"
class="flex column w100" id="tabs"
:breakpoint="0"
class="w100 text-grey-7 bg-grey-2 shadow-1 rounded-fix"
style="z-index: 1000;"
:style="minWidthTab"
> >
<template #default> <q-route-tab
<div class="flex column items-center"> v-for="tab in tabs"
<q-icon :name="tab.icon" :size="maxTabWidth < baseTabWidth ? 'sm' : 'md'"/> :to="{ name: tab.name }"
<div :name="tab.name"
class="text-caption flex justify-center" :key="tab.name"
:style="{ width: (baseTabWidth - 32) + 'px'}" :ripple="false"
v-if="maxTabWidth < baseTabWidth" no-caps
dense
:class="(tabSelect === tab.name ? 'active' : '')"
>
<div class="column items-center">
<q-icon :name="tab.icon" :size="config?.icon"/>
<span
v-if="config?.showLabel"
class="text-caption"
> >
<span> {{ $t(tab.label) }}
<q-resize-observer @resize="size => tab.width = size.width"/> </span>
{{$t(tab.label)}}
</span>
</div>
</div> </div>
</template> </q-route-tab>
</q-route-tab> <q-resize-observer @resize="size => tabsWidth = size.width" />
<q-resize-observer @resize="size => tabsWidth = size.width" /> </q-tabs>
</q-tabs> <div class="bg-transparent"
</q-footer> style="height: var(--tg-safe-area-inset-bottom)"
/>
</div>
</div>
</template> </template>
</pn-page-card> </pn-page-wrapper>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onBeforeMount, computed } from 'vue' import { ref, onBeforeMount, computed } from 'vue'
import { useChatsStore } from 'stores/chats'
import { useCompaniesStore } from 'stores/companies'
import { useUsersStore } from 'stores/users'
import projectPageHeader from 'pages/project-page/ProjectPageHeader.vue' import projectPageHeader from 'pages/project-page/ProjectPageHeader.vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const chatsStore = useChatsStore() const tabs = [
const usersStore = useUsersStore() { name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline' },
const companiesStore = useCompaniesStore() { name: 'users', label: 'project__users', icon: 'mdi-account-outline' },
{ name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline' }
const chatsQty = computed(() => chatsStore.chats.length) ] as const
const usersQty = computed(() => usersStore.users.length)
const companiesQty = computed(() => companiesStore.getCompanies.length)
const tabs = ref([
{name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline', to: { name: 'chats'}, qty: chatsQty.value, width: 0 },
{name: 'users', label: 'project__users', icon: 'mdi-account-outline', to: { name: 'users'}, qty: usersQty.value, width: 0 },
{name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline', to: { name: 'companies'}, qty: companiesQty.value, width: 0 }
])
// hidden icon name if overflow - with resize-observer
const tabsWidth = ref(0)
const baseTabWidth = computed(() => Math.floor(tabsWidth.value / 3))
const maxTabWidth = computed(() => Math.max(...tabs.value.map(el => el.width)))
const tabSelect = ref<string>() const tabSelect = ref<string>()
onBeforeMount(() => { onBeforeMount(() => {
const initialTab = tabs.value.find(t => t.to.name === route.name)?.name || tabs.value[0]?.name || '' const initialTab = tabs.find(t => t.name === route.name)?.name || tabs[0]?.name || ''
tabSelect.value = initialTab tabSelect.value = initialTab
}) })
const tabsSlotHeight = ref(0)
interface sizeParams {
height: number,
width: number
}
function onTabsSlotResize(size: sizeParams) {
tabsSlotHeight.value = size.height
}
// hidden icon name if overflow - with resize-observer
const tabsWidth = ref(0)
const widthTabLabel = ref(new Array(tabs.length).fill(0))
function onTabResize (size: sizeParams, idx: number) {
if (size.width) widthTabLabel.value[idx] = size.width
}
const config = computed(() => {
const ICON_SM = 24
const ICON_MD = 32
const availableWidth = tabsWidth.value
const variants = [
{ icon: 'sm', gap: 16, showLabel: true },
{ icon: 'sm', gap: 8, showLabel: true },
{ icon: 'md', gap: 16, showLabel: false },
{ icon: 'md', gap: 8, showLabel: false },
{ icon: 'sm', gap: 16, showLabel: false },
{ icon: 'sm', gap: 8, showLabel: false }
] as const
for (const v of variants) {
const iconW = v.icon === 'sm' ? ICON_SM : ICON_MD
let totalNeeded = 0
tabs.forEach((_, idx) => {
const labelW = v.showLabel ? widthTabLabel.value[idx] : 0
const contentW = Math.max(iconW, labelW)
totalNeeded += contentW + (v.gap * 2)
})
if (totalNeeded <= availableWidth || v === variants[variants.length - 1]) {
return v
}
}
return variants[variants.length - 1]
})
const minWidthTab = computed(() => {
const activeIdx = tabs.findIndex(el => el.name === tabSelect.value)
const currentGap = config.value?.gap ?? 8
const labelW = config.value?.showLabel ? widthTabLabel.value[activeIdx] : 0
const iconW = config.value?.icon === 'sm' ? 24 : 32
const activeContentWidth = Math.max(labelW, iconW)
return {
'--current-gap': `${currentGap}px`,
'--min-width-tab': '1px',
'--active-tab-width': (activeContentWidth + 16 * 2) + 'px',
'--tabs-height': tabsSlotHeight.value - 32 + 'px'
}
})
</script> </script>
<style> <style scoped lang="scss">
.active { #tabs :deep(.q-tab) {
padding: 0 var(--current-gap);
}
#tabs :deep(.active) {
color: var(--q-primary) color: var(--q-primary)
} }
.tab-panel-color { #tabs :deep(.q-router-link--active .q-tab__indicator) {
background: transparent height: calc(var(--tabs-height) - var(--tg-safe-area-inset-bottom, 0px));
border-radius: calc(var(--tabs-height) / 2);
overflow: hidden;
opacity: 0.2;
background-color: #999;
} }
.full-height-panel > .q-panel { #tabs :deep(.q-focus-helper) {
display: flex; border-radius: calc(var(--tabs-height) / 2);
flex-direction: column; border: 0px;
flex-grow: 2;
width: 100%
} }
</style> #tabs :deep(.q-tabs__arrow) {
display: none;
}
<style scoped> #tabs :deep(.q-tab__content) {
#tabs :deep(.q-tab__indicator) { min-width: var(--min-width-tab);
height: 3px !important; }
}
#tabs :deep(.q-tab--active .q-tab__indicator) {
width: var(--active-tab-width);
right: inherit;
left: inherit;
}
</style> </style>

View File

@@ -1,7 +1,9 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t('projects__title') }} <div class="q-pr-md ellipsis">
{{ $t('projects__title') }}
</div>
<q-btn <q-btn
@click="goAccount" @click="goAccount"
flat rounded flat rounded
@@ -14,41 +16,117 @@
<pn-account-block-name/> <pn-account-block-name/>
</q-btn> </q-btn>
</template> </template>
<pn-scroll-list <template #card-header>
:hideHeader="projects.length === 0 && archiveProjects.length === 0" <pn-action-bar
> v-model="searchProject"
<template #card-body-header> placeholder="projects__search"
<q-input :show-calendar-btn="false"
v-model="searchProject" :show-filter-btn="false"
clearable />
clear-icon="close" </template>
:placeholder="$t('projects__search')"
dense filled
class="col-grow q-px-md q-py-md"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</template>
<q-list separator v-if="projects.length !== 0"> <q-list v-if="projects.length !== 0">
<template <template
v-for = "item in activeProjects" v-for="item in activeProjects"
:key="item.id" :key="item.id"
>
<q-slide-item
@right="handleSlideRight($event, item.id)"
clickable
v-ripple
@click="goProject(item.id)"
right-color="red"
left-color="green"
> >
<q-slide-item <template #right>
@right="handleSlideRight($event, item.id)" <q-icon size="lg" name="mdi-briefcase-remove-outline"/>
clickable </template>
v-ripple <q-item>
@click="goProject(item.id)" <q-item-section avatar>
right-color="red" <pn-auto-avatar
left-color="green" :img="item.logo"
> :name="item.name"
<template #right> type="rounded"
<q-icon size="lg" name="mdi-briefcase-remove-outline"/> size="lg"
</template> />
<q-item> </q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
</q-item-label>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{ item.description }}
</q-item-label>
<q-item-label lines="1" class="text-caption text-grey">
<div class="flex items-center">
<div class="flex items-center">
<q-icon name="mdi-chat-outline"/>
<span>{{ item.chat_count }} </span>
</div>
<div class="flex items-center q-ml-xs">
<q-icon name="mdi-account-outline"/>
<span>{{ item.user_count }}</span>
</div>
</div>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>
<q-btn
color="grey"
size="md"
dense flat round
icon="mdi-pencil"
class="q-ml-sm"
@click.stop="editProject(item.id)"
/>
</q-item-label>
</q-item-section>
</q-item>
</q-slide-item>
</template>
</q-list>
<div
v-if="archiveProjects.length !== 0"
class="flex column items-center w100"
:class="showArchive ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showArchive=!showArchive"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showArchive }"
>
<span class="text-caption">
{{ !showArchive
? $t('projects__show_archive') + ' (' + archiveProjects.length +')'
: $t('projects__hide_archive')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list v-if="showArchive" class="w100">
<q-item
v-for = "item in archiveProjects"
:key="item.id"
clickable
v-ripple
@click="handleArchiveList(item.id)"
class="w100 text-grey"
>
<q-item-section avatar> <q-item-section avatar>
<pn-auto-avatar <pn-auto-avatar
:img="item.logo" :img="item.logo"
@@ -58,139 +136,51 @@
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label lines="1" class="text-bold"> <q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
{{ item.name }}
</q-item-label>
<q-item-label <q-item-label
caption lines="2" caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line" style="max-width: -webkit-fill-available; white-space: pre-line"
> >
{{ item.description }} {{ item.description }}
</q-item-label> </q-item-label>
<q-item-label lines="1" class="text-caption text-grey">
<div class="flex items-center">
<div class="flex items-center">
<q-icon name="mdi-chat-outline"/>
<span>{{ item.chat_count }} </span>
</div>
<div class="flex items-center q-ml-xs">
<q-icon name="mdi-account-outline"/>
<span>{{ item.user_count }}</span>
</div>
</div>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label>
<q-btn
color="grey"
size="md"
dense flat round
icon="mdi-pencil"
class="q-ml-sm"
@click.stop="editProject(item.id)"
/>
</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-slide-item> </q-list>
</template> </transition>
</q-list>
<div
v-if="archiveProjects.length !== 0"
class="flex column items-center w100"
:class="showArchive ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showArchive=!showArchive"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showArchive }"
>
<span class="text-caption">
{{ !showArchive
? $t('projects__show_archive') + ' (' + archiveProjects.length +')'
: $t('projects__hide_archive')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showArchive" class="w100">
<q-item
v-for = "item in archiveProjects"
:key="item.id"
clickable
v-ripple
@click="handleArchiveList(item.id)"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{ item.description }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</transition>
</div>
</div> </div>
</div>
<pn-onboard-btn <pn-onboard-btn
v-if="projects.length===0 && projectsInit" v-if="projects.length===0 && projectsInit"
icon="mdi-briefcase-plus-outline" icon="mdi-briefcase-plus-outline"
:message1="$t('projects__lets_start')" message1="projects__lets_start"
:message2="$t('projects__lets_start_description')" message2="projects__lets_start_description"
@btn-click="createNewProject" @btn-click="createNewProject"
/> />
<div <div
class="flex column justify-center items-center w100" class="column justify-center items-center w100"
style="position: absolute; bottom: 0;" style="position: absolute; bottom: 0;"
v-if="!projectsInit" v-if="!projectsInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
<q-page-sticky
position="bottom-right"
:offset="[0, 18]"
class="fix-fab-offset"
> >
<q-linear-progress indeterminate />
</div>
<template #card-fab>
<transition <transition
appear appear
enter-active-class="animated zoomIn" enter-active-class="animated zoomIn"
> >
<pn-glass-btn type="fab" color="primary">
<q-btn <q-btn
fab fab
icon="add" icon="add"
color="brand" color="transparent"
@click="createNewProject" @click="createNewProject"
/> />
</pn-glass-btn>
</transition> </transition>
</q-page-sticky> </template>
</pn-page-card> </pn-page-template>
<pn-small-dialog <pn-small-dialog
v-model="showDialogArchiveProject" v-model="showDialogArchiveProject"
@@ -221,6 +211,8 @@
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import pnGlassBtn from 'components/pnGlassBtn.vue'
import pnActionBar from 'components/pnActionBar.vue'
import type { Project } from 'types/Project' import type { Project } from 'types/Project'
const router = useRouter() const router = useRouter()

View File

@@ -1,5 +1,8 @@
<template> <template>
<doc-block title="consent__title" document-name="Consent_to_Personal_Data_Processing"/> <doc-block
title="account__consent_pd"
document-name="Consent_to_Personal_Data_Processing"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,5 +1,8 @@
<template> <template>
<doc-block title="privacy__title" document-name="Privacy-Policy"/> <doc-block
title="account__privacy"
document-name="Privacy-Policy"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,87 +1,85 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t('settings__title') }} {{ $t('settings__title') }}
</template> </template>
<pn-scroll-list> <q-list>
<q-list separator> <q-item-label header>{{ $t('settings__software_title') }}</q-item-label>
<q-item-label header>{{ $t('settings__software_title') }}</q-item-label>
<pn-item-btm-dialog
title="settings__language"
caption="settings__software_title"
icon="mdi-translate"
iconColor="primary"
>
<template #value>
{{ localeOptions.find(el => el.value === locale)?.label }}
</template>
<pn-list-selector
v-model="locale"
:options="localeOptions"
/>
</pn-item-btm-dialog>
<pn-item-btm-dialog
title="settings__font_size"
caption="settings__software_title"
icon="mdi-format-size"
iconColor="primary"
>
<template #value>
{{ $t(fontSizeLabel) }}
</template>
<pn-list-selector
v-model="fontSize"
:options="fontSizeOptions"
/>
</pn-item-btm-dialog>
<template v-if="authStore.isAuth">
<q-item-label
class="q-mt-md"
header
>
{{ $t('settings__bot_title') }}
</q-item-label>
<pn-item-btm-dialog <pn-item-btm-dialog
title="settings__language" title="settings__language"
caption="settings__software_title" caption="settings__bot_title"
icon="mdi-translate" icon="mdi-translate"
iconColor="primary" iconColor="primary"
> >
<template #value> <template #value>
{{ localeOptions.find(el => el.value === locale)?.label }} {{ localeOptions.find(el => el.value === localeBot)?.label }}
</template> </template>
<pn-list-selector <pn-list-selector
v-model="locale" v-model="localeBot"
:options="localeOptions" :options="localeOptions"
/> />
</pn-item-btm-dialog> </pn-item-btm-dialog>
<pn-item-btm-dialog <pn-item-btm-dialog
title="settings__font_size" title="settings__timezone"
caption="settings__software_title" caption="settings__bot_title"
icon="mdi-format-size" icon="mdi-map-clock-outline"
iconColor="primary" iconColor="primary"
v-if="timeZoneBot"
> >
<template #value> <template #value>
{{ $t(fontSizeLabel) }} {{ timeZoneBot.tz }}
{{ timeZoneBot.offsetString }}
</template> </template>
<pn-list-selector
v-model="fontSize" <pn-time-zone-selector
:options="fontSizeOptions" v-model="timeZoneBot"
:locale
/> />
</pn-item-btm-dialog> </pn-item-btm-dialog>
</template>
<template v-if="authStore.isAuth"> </q-list>
<q-item-label </pn-page-template>
class="q-mt-md"
header
>
{{ $t('settings__bot_title') }}
</q-item-label>
<pn-item-btm-dialog
title="settings__language"
caption="settings__bot_title"
icon="mdi-translate"
iconColor="primary"
>
<template #value>
{{ localeOptions.find(el => el.value === localeBot)?.label }}
</template>
<pn-list-selector
v-model="localeBot"
:options="localeOptions"
/>
</pn-item-btm-dialog>
<pn-item-btm-dialog
title="settings__timezone"
caption="settings__bot_title"
icon="mdi-map-clock-outline"
iconColor="primary"
v-if="timeZoneBot"
>
<template #value>
{{ timeZoneBot.tz }}
{{ timeZoneBot.offsetString }}
</template>
<pn-time-zone-selector
v-model="timeZoneBot"
:locale
/>
</pn-item-btm-dialog>
</template>
</q-list>
</pn-scroll-list>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,75 +1,75 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{$t('subscribe__title')}} {{$t('subscribe__title')}}
</template> </template>
<pn-scroll-list>
<template #card-body-header>
<div
v-if="tariffs && currentTariff"
id="subscribe-current-balance"
class="flex w100 q-px-md q-py-md row"
>
<div class="flex w100 justify-between items-center no-wrap"> <template #card-header>
<div class="flex items-center text-grey"> <div
{{ $t('subscribe__current_tariff_title') }} v-if="tariffs && currentTariff"
</div> id="subscribe-current-balance"
class="flex w100 q-px-md q-py-md row bg-grey-11"
>
<div class="flex items-center column text-right"> <div class="flex w100 justify-between items-center no-wrap">
<span class="text-bold"> <div class="flex items-center text-grey">
{{ currentTariff?.tariffInfo.name }} {{ $t('subscribe__current_tariff_title') }}
</span>
</div>
</div> </div>
<div
class="row w100 no-wrap items-center text-caption justify-between" <div class="flex items-center column text-right">
v-if="!isCurrentTariffTest" <span class="text-bold">
> {{ currentTariff?.tariffInfo.name }}
<div class="text-grey text-no-wrap ellipsis">
{{ $t('subscribe__current_tariff_date') }}
</div>
<span class="text-no-wrap text-right" v-if="currentTariff.end_date">
{{ date.formatDate(currentTariff.end_date * 1000, 'DD.MM.YYYY') }}
</span> </span>
</div> </div>
</div>
<div
class="row w100 no-wrap items-center text-caption justify-between"
v-if="!isCurrentTariffTest"
>
<div class="text-grey text-no-wrap ellipsis">
{{ $t('subscribe__current_tariff_date') }}
</div>
<span class="text-no-wrap text-right" v-if="currentTariff.end_date">
{{ date.formatDate(currentTariff.end_date * 1000, 'DD.MM.YYYY') }}
</span>
</div>
<div class="row w100 no-wrap items-center text-caption text-grey justify-between"> <div class="row w100 no-wrap items-center text-caption text-grey justify-between">
<div> <div>
{{ $t('subscribe__current_tariff_chat_limit') }} {{ $t('subscribe__current_tariff_chat_limit') }}
</div> </div>
<div class="text-right"> <div class="text-right">
<span <span
class="text-bold q-pr-xs" class="text-bold q-pr-xs"
:class="currentTariff.tariffInfo.chat_limit && (currentTariff.tariffInfo.chat_limit > currentTariff?.used_chats) ? 'negative' : ''" :class="currentTariff.tariffInfo.chat_limit && (currentTariff.tariffInfo.chat_limit > currentTariff?.used_chats) ? 'negative' : ''"
> >
{{ currentTariff.used_chats }} {{ currentTariff.used_chats }}
</span> </span>
<span v-if="currentTariff.tariffInfo.chat_limit"> <span v-if="currentTariff.tariffInfo.chat_limit">
/{{ currentTariff.tariffInfo.chat_limit }} /{{ currentTariff.tariffInfo.chat_limit }}
</span> </span>
</div>
</div> </div>
</div> </div>
</template>
<div v-if="tariffs && currentTariff">
<subscribe-upgrade
v-if="isCurrentTariffTest"
:tariffs
:periods
@tariff-upgrade="prepareTgBtn"
/>
<subscribe-change-renew
v-else
:tariffs
:periods
:currentTariff
@update="prepareTgBtn"
/>
</div> </div>
</pn-scroll-list> </template>
</pn-page-card>
<div v-if="tariffs && currentTariff">
<subscribe-upgrade
v-if="isCurrentTariffTest"
:tariffs
:periods
@tariff-upgrade="prepareTgBtn"
/>
<subscribe-change-renew
v-else
:tariffs
:periods
:currentTariff
@update="prepareTgBtn"
/>
</div>
</pn-page-template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,40 +1,38 @@
<template> <template>
<pn-page-card> <pn-page-template>
<template #title> <template #title>
{{ $t('support__title') }} {{ $t('support__title') }}
</template> </template>
<pn-scroll-list> <div class="q-pa-md flex column w100 items-center">
<div class="q-pa-md flex column w100 items-center"> <div align="center" >
<div align="center" > <div>{{ $t('support__work_time_text') }}</div>
<div>{{ $t('support__work_time_text') }}</div> <div class="flex items-center justify-center text-caption">
<div class="flex items-center justify-center text-caption"> <q-icon name="mdi-clock-time-four-outline"/>
<q-icon name="mdi-clock-time-four-outline"/> <div>{{ $t('support__work_time_time') }}</div>
<div>{{ $t('support__work_time_time') }}</div>
</div>
</div>
<q-btn
no-caps
icon="telegram"
color="primary"
rounded
@click="openSupport"
class="q-ma-lg"
>
<span class="q-pl-xs no-wrap">{{ $t('support__ask_question') }}</span>
</q-btn>
<div class="orline w100 text-grey">
<span class="q-mx-sm text-caption">{{ $t('support__or') }}</span>
</div>
<div class="flex items-center justify-center text-primary q-ma-md">
<q-icon name="mail" class="q-mr-xs"/>
{{ supportEmailAddress }}
</div> </div>
</div> </div>
</pn-scroll-list> <q-btn
</pn-page-card> no-caps
icon="telegram"
color="primary"
rounded
@click="openSupport"
class="q-ma-lg"
>
<span class="q-pl-xs no-wrap">{{ $t('support__ask_question') }}</span>
</q-btn>
<div class="orline w100 text-grey">
<span class="q-mx-sm text-caption">{{ $t('support__or') }}</span>
</div>
<div class="flex items-center justify-center text-primary q-ma-md">
<q-icon name="mail" class="q-mr-xs"/>
{{ supportEmailAddress }}
</div>
</div>
</pn-page-template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,5 +1,8 @@
<template> <template>
<doc-block title="terms_of_use__title" document-name="Terms_of_use"/> <doc-block
title="account__terms_of_use"
document-name="Terms_of_use"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,326 +1,315 @@
<template> <template>
<div class="q-pa-none flex column col-grow no-scroll"> <pn-page-card v-bind="$attrs">
<pn-scroll-list <template #card-header>
:hideHeader="chats.length === 0" <pn-action-bar
> v-if="chats.length !== 0"
<template #card-body-header> v-model="search"
<pn-action-bar placeholder="chats__search"
v-model="search" :show-calendar-btn="false"
placeholder="chats__search" :show-filter-btn="false"
:show-calendar-btn="false"
:show-filter-btn="false"
/>
</template>
<template #card-body-btn>
<div
v-if="chats.length !== 0 || obsoleteChats.length !== 0"
class="w100 flex justify-end q-px-md"
>
<q-btn
@click="showDialogHelp = !showDialogHelp"
flat dense no-caps
class="q-my-xs"
>
<div class="text-caption flex no-wrap items-center q-px-xs text-grey">
<span class="q-pr-xs">
{{ $t("chats__help_chat_type") }}
</span>
<q-icon name="mdi-help-circle-outline" size="xs"/>
</div>
</q-btn>
</div>
</template>
<q-list separator v-if="chats.length !== 0">
<q-slide-item
v-for="item in displayChats"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
@click="goChatInfo(item.id)"
>
<template #right>
<q-icon size="lg" name="mdi-link-off"/>
</template>
<q-item
clickable
v-ripple
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
</q-item-label>
<q-item-label caption lines="2">
{{ item.description }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row no-wrap items-center">
<span class="text-caption q-pr-xs">
{{ getDate(item.bot_access_date) }}
</span>
<q-icon
:name="chatAccess(item.bot_access).icon"
:color="chatAccess(item.bot_access).color"
size="xs"
/>
</div>
<div class="flex items-center no-wrap text-caption">
<!-- if chat reconnect -->
<q-icon
v-if="item.state === 2"
:name="chatState(2).icon"
:color="chatState(2).color"
size="xs"
class="q-pr-sm"
/>
<div class="flex no-wrap items-center">
<q-icon name="mdi-account-outline"/>
<span>{{ item.user_count }}</span>
</div>
</div>
</q-item-section>
</q-item>
</q-slide-item>
</q-list>
<div
v-if="obsoleteChats.length !== 0"
class="flex column items-center w100"
:class="showObsoleteChats ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showObsoleteChats=!showObsoleteChats"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showObsoleteChats }"
>
<span class="text-caption">
{{ !showObsoleteChats
? $t('chats__show_obsolete_chats') + ' (' + obsoleteChats.length +')'
: $t('chats__hide_obsolete_chats')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showObsoleteChats" class="w100">
<q-item
v-for = "item in obsoleteChats"
:key="item.id"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div
class="row no-wrap items-center"
v-if="item.state_date"
>
<span class="text-caption q-pr-xs">
{{ getDate(item.state_date) }}
</span>
<q-icon
:name="chatState(item.state).icon"
:color="chatState(item.state).color"
size="xs"
/>
</div>
</q-item-section>
</q-item>
</q-list>
</transition>
</div>
</div>
<pn-onboard-btn
v-if="chats.length === 0 && obsoleteChats.length === 0 && chatsInit"
icon="mdi-chat-plus-outline"
:message1="$t('chats__onboard_msg1')"
:message2="$t('chats__onboard_msg2')"
@click="showOverlay=true; fabState=true"
/> />
<div </template>
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!chatsInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
<q-page-sticky <q-list v-if="chats.length !== 0">
:style="{ zIndex: !showOverlay ? 'inherit' : '5100 !important' }" <q-slide-item
position="bottom-right" v-for="item in displayChats"
:offset="[0, 18]" :key="item.id"
class="fix-fab-offset" @right="handleSlide($event, item.id)"
right-color="red"
@click="goChatInfo(item.id)"
>
<template #right>
<q-icon size="lg" name="mdi-link-off"/>
</template>
<q-item
clickable
v-ripple
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
</q-item-label>
<q-item-label caption lines="2">
{{ item.description }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row no-wrap items-center">
<span class="text-caption q-pr-xs">
{{ getDate(item.bot_access_date) }}
</span>
<q-icon
:name="chatAccess(item.bot_access).icon"
:color="chatAccess(item.bot_access).color"
size="xs"
/>
</div>
<div class="flex items-center no-wrap text-caption">
<!-- if chat reconnect -->
<q-icon
v-if="item.state === 2"
:name="chatState(2).icon"
:color="chatState(2).color"
size="xs"
class="q-pr-sm"
/>
<div class="flex no-wrap items-center">
<q-icon name="mdi-account-outline"/>
<span>{{ item.user_count }}</span>
</div>
</div>
</q-item-section>
</q-item>
</q-slide-item>
</q-list>
<div
v-if="displayObsoleteChats.length !== 0"
class="flex column items-center w100"
:class="showObsoleteChats ? 'bg-grey-12' : ''"
> >
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showObsoleteChats = !showObsoleteChats"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showObsoleteChats }"
>
<span class="text-caption">
{{ !showObsoleteChats
? $t('chats__show_obsolete_chats') + ' (' + displayObsoleteChats.length +')'
: $t('chats__hide_obsolete_chats')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list v-if="showObsoleteChats" class="w100">
<q-item
v-for = "item in displayObsoleteChats"
:key="item.id"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div
class="row no-wrap items-center"
v-if="item.state_date"
>
<span class="text-caption q-pr-xs">
{{ getDate(item.state_date) }}
</span>
<q-icon
:name="chatState(item.state).icon"
:color="chatState(item.state).color"
size="xs"
/>
</div>
</q-item-section>
</q-item>
</q-list>
</transition>
</div>
</div>
<pn-onboard-btn
v-if="chats.length === 0 && obsoleteChats.length === 0 && chatsInit"
icon="mdi-chat-plus-outline"
message1="chats__onboard_msg1"
message2="chats__onboard_msg2"
@click="showAttachChatDialog=true; fabState=true"
/>
<div
class="column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!chatsInit"
>
<q-linear-progress indeterminate />
</div>
<template #card-fab>
<div v-if="chats.length !== 0 || obsoleteChats.length !== 0">
<transition
appear
enter-active-class="animated zoomIn"
>
<pn-glass-btn
type="fab"
color="grey-2"
>
<q-btn
@click="showDialogHelp = !showDialogHelp"
fab
icon="mdi-help"
color="transparent"
class="text-grey"
/>
</pn-glass-btn>
</transition>
</div>
<transition <transition
appear appear
enter-active-class="animated zoomIn" enter-active-class="animated zoomIn"
> >
<q-fab <pn-glass-btn
v-model="fabState" type="fab"
v-if="showFab" color="primary"
icon="add"
color="brand"
:class="isEnableChatAdd ? '' : 'fab-disabled'"
direction="up"
vertical-actions-align="right"
@click="showOverlay = !showOverlay"
:disable="!tg.initData"
> >
<q-fab-action <q-btn
square @click="showAttachChatDialog = !showAttachChatDialog"
clickable fab
v-ripple icon="add"
class="bg-white change-fab-action q-pa-none" color="transparent"
style="cursor: default" :disable="!tg.initData"
label-style="" />
> </pn-glass-btn>
<template #icon>
<q-list class="q-py-sm" v-if="isEnableChatAdd">
<q-item
v-for="item in fabMenu"
:key="item.id"
class="w100"
:clickable="item.type !== 'header'"
v-ripple
@click="item.type !== 'header' && item.func ? item?.func() : () => {}"
>
<template v-if="item.type === 'header'">
<div class="w100 column" :class="item.id === 4 ? 'q-pt-md' : ''">
<span
class="w100 text-center text-bold"
:class="'text-' + item.color"
style="line-height: 1rem;">
{{ $t(item.title) }}
</span>
<div class="text-caption text-center text-grey fab-action-item">
{{ $t(item.description) }}
</div>
</div>
</template>
<template v-else>
<q-item-section avatar class="items-center">
<q-avatar :color="item.color" rounded text-color="white" :icon="item.icon" />
</q-item-section>
<q-item-section class="items-start">
<q-item-label class="fab-action-item">
{{ $t(item.title) }}
</q-item-label>
<q-item-label caption class="fab-action-item text-left">
{{ $t(item.description) }}
</q-item-label>
</q-item-section>
</template>
</q-item>
</q-list>
<div v-else class="w100 bg-white column q-py-md q-px-md rounded-card fab-action-item">
<div class="text-negative">
{{ $t('chats__disable_add_chat') }}
</div>
<span class="text-caption text-grey">
{{ $t('chats__disable_add_chat_description') }}
</span>
</div>
</template>
</q-fab-action>
</q-fab>
</transition> </transition>
</q-page-sticky> </template>
</div>
<pn-overlay v-if="showOverlay"/> <pn-simple-scroll-dialog
v-model="showAttachChatDialog"
title="chats__attach_dialog_title"
>
<q-list v-if="isEnableChatAdd">
<q-item
v-for="item in fabMenu"
:key="item.id"
class="w100"
:clickable="item.type !== 'header'"
:v-ripple="item.type !== 'header'"
@click="item.type !== 'header' && item.func
? (showAttachChatDialog = false, item.func())
: null"
>
<template v-if="item.type === 'header'">
<div class="w100 column" :class="item.id === 4 ? 'q-pt-md' : ''">
<span
class="w100 text-center text-bold"
:class="'text-' + item.color"
style="line-height: 1rem;">
{{ $t(item.title) }}
</span>
<div class="text-caption text-center text-grey fab-action-item">
{{ $t(item.description) }}
</div>
</div>
</template>
<template v-else>
<q-item-section avatar class="items-center">
<q-avatar :color="item.color" rounded text-color="white" :icon="item.icon" />
</q-item-section>
<pn-small-dialog <q-item-section class="items-start">
v-model="showDialogDeleteChat" <q-item-label class="fab-action-item">
icon="mdi-link-off" {{ $t(item.title) }}
color="negative" </q-item-label>
title="chats__dialog_unlink_title" <q-item-label caption class="fab-action-item text-left">
message1="chats__dialog_unlink_message" {{ $t(item.description) }}
message2="chats__dialog_unlink_message2" </q-item-label>
mainBtnLabel="chats__dialog_unlink_ok" </q-item-section>
@clickMainBtn="onConfirm()" </template>
@close="onCancel()" </q-item>
@before-hide="onDialogBeforeHide()"
/>
<pn-simple-scroll-dialog
v-model="showDialogHelp"
title="chat__help_title"
>
<div class="q-py-none q-px-md q-ma-none">
<q-list class="q-pt-sm">
<pn-chat-type-item :access="2"/>
<pn-chat-type-item :access="1"/>
<pn-chat-type-item :state="2"/>
<pn-chat-type-item :state="3"/>
<pn-chat-type-item :state="4"/>
</q-list> </q-list>
<q-separator class="q-mt-md"/>
<div class="text-caption q-pt-md"> <div v-else class="w100 bg-white column q-py-md q-px-md rounded-card fab-action-item">
{{ $t('chat_type__dialog_func_title') }} <div class="text-negative">
<ol class="q-px-md q-my-xs"> {{ $t('chats__disable_add_chat') }}
<li>{{ $t('chat_type__dialog_func_point1') }}</li> </div>
<li>{{ $t('chat_type__dialog_func_point2') }}</li> <span class="text-caption text-grey">
<li>{{ $t('chat_type__dialog_func_point3') }}</li> {{ $t('chats__disable_add_chat_description') }}
</ol> </span>
<div class="q-pt-md"> </div>
{{ $t('chat_type__dialog_administrator_rights')}} </pn-simple-scroll-dialog>
<pn-small-dialog
v-model="showDialogDeleteChat"
icon="mdi-link-off"
color="negative"
title="chats__dialog_unlink_title"
message1="chats__dialog_unlink_message"
message2="chats__dialog_unlink_message2"
mainBtnLabel="chats__dialog_unlink_ok"
@clickMainBtn="onConfirm()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
<pn-simple-scroll-dialog
v-model="showDialogHelp"
title="chat__help_title"
>
<div class="q-py-none q-px-md q-ma-none">
<q-list>
<pn-chat-type-item :access="2"/>
<pn-chat-type-item :access="1"/>
<pn-chat-type-item :state="2"/>
<pn-chat-type-item :state="3"/>
<pn-chat-type-item :state="4"/>
</q-list>
<q-separator class="q-mt-md"/>
<div class="text-caption q-pt-md">
{{ $t('chat_type__dialog_func_title') }}
<ol class="q-px-md q-my-xs">
<li>{{ $t('chat_type__dialog_func_point1') }}</li>
<li>{{ $t('chat_type__dialog_func_point2') }}</li>
<li>{{ $t('chat_type__dialog_func_point3') }}</li>
</ol>
<div class="q-pt-md">
{{ $t('chat_type__dialog_administrator_rights')}}
</div>
</div> </div>
</div> </div>
</div> </pn-simple-scroll-dialog>
</pn-simple-scroll-dialog> <template #overlay>
<pn-overlay v-if="showAttachChatDialog" style="z-index:1005;"/>
</template>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount, inject } from 'vue' import { ref, computed, inject } from 'vue'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { useChatsStore } from 'stores/chats' import { useChatsStore } from 'stores/chats'
import type { WebApp } from '@twa-dev/types' import type { WebApp } from '@twa-dev/types'
import type { Chat } from 'types/Chat' import type { Chat, ObsoleteChat } from 'types/Chat'
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { date } from 'quasar' import { date } from 'quasar'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import pnChatTypeItem from 'components/pnChatTypeItem.vue' import pnChatTypeItem from 'components/pnChatTypeItem.vue'
import pnSimpleScrollDialog from 'components/pnSimpleScrollDialog.vue' import pnSimpleScrollDialog from 'components/pnSimpleScrollDialog.vue'
import pnActionBar from 'components/pnActionBar.vue' import pnActionBar from 'components/pnActionBar.vue'
import pnGlassBtn from 'components/pnGlassBtn.vue'
import { chatAccess, chatState } from 'utils/chats' import { chatAccess, chatState } from 'utils/chats'
const router = useRouter() const router = useRouter()
@@ -329,7 +318,7 @@
const { t } = useI18n() const { t } = useI18n()
const search = ref('') const search = ref('')
const showOverlay = ref<boolean>(false) const showAttachChatDialog = ref<boolean>(false)
const chatsStore = useChatsStore() const chatsStore = useChatsStore()
const showDialogDeleteChat = ref<boolean>(false) const showDialogDeleteChat = ref<boolean>(false)
const deleteChatId = ref<number | undefined>(undefined) const deleteChatId = ref<number | undefined>(undefined)
@@ -444,28 +433,6 @@
await router.push({ name: 'chat_info', params: { id: route.params.id, chatId }}) await router.push({ name: 'chat_info', params: { id: route.params.id, chatId }})
} }
// fix fab jumping
const showFab = ref(false)
const timerId = ref<ReturnType<typeof setTimeout> | null>(null)
onActivated(() => {
timerId.value = setTimeout(() => {
showFab.value = true
}, 300)
})
onDeactivated(() => {
showFab.value = false
if (timerId.value) {
clearTimeout(timerId.value)
timerId.value = null
}
})
onBeforeUnmount(() => {
if (timerId.value) clearTimeout(timerId.value)
})
function getDate (d: number) { function getDate (d: number) {
return new Date(d * 1000).getFullYear() === new Date(Date.now()).getFullYear() return new Date(d * 1000).getFullYear() === new Date(Date.now()).getFullYear()
? date.formatDate(d * 1000, 'DD MMM') ? date.formatDate(d * 1000, 'DD MMM')
@@ -475,31 +442,19 @@
const obsoleteChats = computed(() => chatsStore.obsoleteChats) const obsoleteChats = computed(() => chatsStore.obsoleteChats)
const showObsoleteChats = ref(false) const showObsoleteChats = ref(false)
const displayObsoleteChats = computed(() => {
return obsoleteChats.value
.filter(searchChats)
function searchChats (el: ObsoleteChat) {
if (!search.value || !(search.value && search.value.trim())) return true
const searchValue = search.value.trim().toLowerCase()
return el.name.toLowerCase().includes(searchValue)
}
})
</script> </script>
<style scoped> <style scoped>
/* width of choose element */
.change-fab-action .q-fab__label--internal {
max-height: none;
}
.change-fab-action :deep(.q-focus-helper) {
visibility: hidden;
}
.q-item :deep(.q-focus-helper) {
visibility: visible;
}
.change-fab-action {
width: calc(min(100vw, var(--body-width)) - 48px) !important;
border-radius: var(--top-radius)
}
.fab-action-item {
text-wrap: auto !important;
}
/* fix mini border after slide */ /* fix mini border after slide */
:deep(.q-slide-item__right) :deep(.q-slide-item__right)
{ {

View File

@@ -1,154 +1,147 @@
<template> <template>
<div class="q-pa-none flex column col-grow no-scroll"> <pn-page-card v-bind="$attrs">
<pn-scroll-list <q-list v-if="companies.length !== 0">
hideHeader <q-slide-item
> v-for="item in displayCompanies"
<template #card-body-btn> :key="item.id"
<div class="flex items-center justify-end q-pa-sm w100" v-if="companies.length !== 0"> @right="handleSlide($event, item.id)"
<q-btn right-color="red"
:color="companies.length <= 2 ? 'grey-6' : 'primary'"
flat
no-caps
@click="maskCompany"
:disable="companies.length <= 2"
class="q-pr-md"
rounded
>
<q-icon
left
size="sm"
name="mdi-domino-mask"
class="q-mr-xs"
/>
<div>
{{ $t('companies__mask')}}
</div>
</q-btn>
</div>
</template>
<q-list separator v-if="companies.length !== 0">
<q-slide-item
v-for="item in displayCompanies"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
>
<template #right v-if="item.id !== myCompany?.id">
<q-icon size="lg" name="mdi-account-multiple-minus-outline"/>
</template>
<q-item
:key="item.id"
clickable
v-ripple
class="w100"
@click="goCompanyInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption text-amber-10" v-if="item.id === myCompany?.id">
<div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/>
{{ $t('companies__my_company') }}
</div>
</q-item-label>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
<q-icon
v-if="companiesStore.checkCompanyMasked(item.id)"
name="mdi-domino-mask"
color="grey"
size="sm"
/>
</q-item-label>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{ item.description }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="flex items-end column">
<span class="text-caption flex items-center">
<q-icon name="mdi-account-outline" color="grey" />
<span>{{ getQtyUsers(item.id) }}</span>
</span>
<div class="flex items-center row text-caption">
<q-icon v-if="item.site" name="mdi-web"/>
<q-icon v-if="item.address" name="mdi-map-marker-outline"/>
<q-icon v-if="item.phone" name="mdi-phone-outline"/>
<q-icon v-if="item.email" name="mdi-email-outline"/>
</div>
</div>
</q-item-section>
</q-item>
</q-slide-item>
</q-list>
<pn-onboard-btn
v-if="companies.length <= 1 && companiesInit"
icon="mdi-account-multiple-plus-outline"
:message1="$t('companies__onboard_msg1')"
:message2="$t('companies__onboard_msg2')"
@btn-click="createCompany"
/>
<div
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!companiesInit"
> >
<q-linear-progress indeterminate /> <template #right v-if="item.id !== myCompany?.id">
</div> <q-icon size="lg" name="mdi-account-multiple-minus-outline"/>
</pn-scroll-list> </template>
<q-item
:key="item.id"
clickable
v-ripple
class="w100"
@click="goCompanyInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.logo"
:name="item.name"
type="rounded"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption text-amber-10" v-if="item.id === myCompany?.id">
<div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/>
{{ $t('companies__my_company') }}
</div>
</q-item-label>
<q-item-label lines="1" class="text-bold">
{{ item.name }}
<q-icon
v-if="companiesStore.checkCompanyMasked(item.id)"
name="mdi-domino-mask"
color="grey"
size="sm"
/>
</q-item-label>
<q-item-label
caption lines="2"
style="max-width: -webkit-fill-available; white-space: pre-line"
>
{{ item.description }}
</q-item-label>
</q-item-section>
<q-page-sticky <q-item-section side>
position="bottom-right" <div class="flex items-end column">
:offset="[0, 18]" <span class="text-caption flex items-center">
class="fix-fab-offset" <q-icon name="mdi-account-outline" color="grey" />
<span>{{ getQtyUsers(item.id) }}</span>
</span>
<div class="flex items-center row text-caption">
<q-icon v-if="item.site" name="mdi-web"/>
<q-icon v-if="item.address" name="mdi-map-marker-outline"/>
<q-icon v-if="item.phone" name="mdi-phone-outline"/>
<q-icon v-if="item.email" name="mdi-email-outline"/>
</div>
</div>
</q-item-section>
</q-item>
</q-slide-item>
</q-list>
<pn-onboard-btn
v-if="companies.length <= 1 && companiesInit"
icon="mdi-account-multiple-plus-outline"
message1="companies__onboard_msg1"
message2="companies__onboard_msg2"
@btn-click="createCompany"
/>
<div
class="column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!companiesInit"
> >
<q-linear-progress indeterminate />
</div>
<template #card-fab>
<div v-if="companies.length >= 3">
<transition
appear
enter-active-class="animated zoomIn"
>
<pn-glass-btn
type="fab"
color="grey-2"
>
<q-btn
@click="maskCompany"
fab
icon="mdi-domino-mask"
color="transparent"
class="text-grey"
/>
</pn-glass-btn>
</transition>
</div>
<transition <transition
appear appear
enter-active-class="animated zoomIn" enter-active-class="animated zoomIn"
> >
<q-btn <pn-glass-btn
v-if="showFab" type="fab"
fab
icon="add"
color="brand" color="brand"
@click="createCompany()" >
/> <q-btn
fab
icon="add"
color="transparent"
@click="createCompany()"
/>
</pn-glass-btn>
</transition> </transition>
</q-page-sticky> </template>
</div>
<pn-small-dialog <pn-small-dialog
v-model="showDialogDeleteCompany" v-model="showDialogDeleteCompany"
icon="mdi-account-multiple-minus-outline" icon="mdi-account-multiple-minus-outline"
color="negative" color="negative"
title="companies__dialog_delete_title" title="companies__dialog_delete_title"
message1="companies__dialog_delete_message" message1="companies__dialog_delete_message"
message2="companies__dialog_delete_message2" message2="companies__dialog_delete_message2"
mainBtnLabel="companies__dialog_delete_ok" mainBtnLabel="companies__dialog_delete_ok"
@clickMainBtn="onConfirm()" @clickMainBtn="onConfirm()"
@close="onCancel()" @close="onCancel()"
@before-hide="onDialogBeforeHide()" @before-hide="onDialogBeforeHide()"
/> />
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount } from 'vue' import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useCompaniesStore } from 'stores/companies' import { useCompaniesStore } from 'stores/companies'
import { useUsersStore } from 'stores/users' import { useUsersStore } from 'stores/users'
import PnGlassBtn from 'components/pnGlassBtn.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -222,28 +215,6 @@
return arr.length return arr.length
} }
// fix fab jumping
const showFab = ref(false)
const timerId = ref<ReturnType<typeof setTimeout> | null>(null)
onActivated(() => {
timerId.value = setTimeout(() => {
showFab.value = true
}, 300)
})
onDeactivated(() => {
showFab.value = false
if (timerId.value) {
clearTimeout(timerId.value)
timerId.value = null
}
})
onBeforeUnmount(() => {
if (timerId.value) clearTimeout(timerId.value)
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -55,10 +55,8 @@
<q-btn <q-btn
@click="toProjects" @click="toProjects"
flat flat no-caps
no-caps dense rounded
dense
rounded
class="q-ml-lg" class="q-ml-lg"
> >
<span class="flex items-center no-wrap q-pl-sm"> <span class="flex items-center no-wrap q-pl-sm">

View File

@@ -1,169 +1,166 @@
<template> <template>
<div class="q-pa-none flex column col-grow no-scroll"> <pn-page-card v-bind="$attrs">
<pn-scroll-list <template #card-header>
:hideHeader="users.length === 0" <pn-action-bar
> v-if="users.length !== 0"
<template #card-body-header> v-model="search"
<pn-action-bar placeholder="users__search"
v-model="search" :show-calendar-btn="false"
placeholder="users__search" :show-filter-btn="false"
:show-calendar-btn="false"
:show-filter-btn="false"
/>
</template>
<q-list separator v-if="users.length !== 0">
<q-slide-item
v-for="item in displayUsers"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
>
<template #right>
<q-icon size="lg" name="mdi-account-remove-outline"/>
</template>
<q-item
:key="item.id"
v-ripple
clickable
class="w100"
@click="goUserInfo(item.id)"
>
<pn-user-list-item-body :item showStatus/>
</q-item>
</q-slide-item>
</q-list>
<!-- LEAVE USERS SECTION -->
<div
v-if="leaveUsers.length!==0"
class="flex column items-center w100"
:class="showLeaveUsers ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showLeaveUsers=!showLeaveUsers"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showLeaveUsers }"
>
<span class="text-caption">
{{ !showLeaveUsers
? $t('users__show_left_users') + ' (' + leaveUsers.length +')'
: $t('users__hide_left_users')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showLeaveUsers" class="w100">
<q-item
v-for = "item in leaveUsers"
:key="item.id"
class="w100 text-grey"
>
<pn-user-list-item-body :item/>
</q-item>
</q-list>
</transition>
</div>
</div>
<!-- END LEAVE USERS SECTION -->
<!-- BLOCKED USERS SECTION -->
<div
v-if="blockedUsers.length!==0"
class="flex column items-center w100"
:class="showBlockedUsers ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showBlockedUsers=!showBlockedUsers"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showBlockedUsers }"
>
<span class="text-caption">
{{ !showBlockedUsers
? $t('users__show_blocked_users') + ' (' + blockedUsers.length +')'
: $t('users__hide_blocked_users')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showBlockedUsers" class="w100">
<q-item
v-for = "item in blockedUsers"
:key="item.id"
clickable
v-ripple
@click="handleUnblockUser(item.id)"
class="w100 text-grey"
>
<pn-user-list-item-body :item/>
</q-item>
</q-list>
</transition>
</div>
</div>
<!-- END BLOCKED USERS SECTION -->
<pn-onboard-btn
v-if="users.length === 0 && usersInit"
icon="mdi-account-outline"
:message1="$t('users__onboard_msg1')"
:message2="$t('users__onboard_msg2')"
noBtn
/> />
<div </template>
class="flex column justify-center items-center w100"
style="position: absolute; bottom: 0;" <q-list v-if="users.length !== 0">
v-if="!usersInit" <q-slide-item
v-for="item in displayUsers"
:key="item.id"
@right="handleSlide($event, item.id)"
right-color="red"
> >
<q-linear-progress indeterminate /> <template #right>
<q-icon size="lg" name="mdi-account-remove-outline"/>
</template>
<q-item
:key="item.id"
v-ripple
clickable
class="w100"
@click="goUserInfo(item.id)"
>
<pn-user-list-item-body :item showStatus/>
</q-item>
</q-slide-item>
</q-list>
<!-- LEAVE USERS SECTION -->
<div
v-if="leaveUsers.length!==0"
class="flex column items-center w100"
:class="showLeaveUsers ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showLeaveUsers=!showLeaveUsers"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showLeaveUsers }"
>
<span class="text-caption">
{{ !showLeaveUsers
? $t('users__show_left_users') + ' (' + leaveUsers.length +')'
: $t('users__hide_left_users')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list v-if="showLeaveUsers" class="w100">
<q-item
v-for = "item in leaveUsers"
:key="item.id"
class="w100 text-grey"
>
<pn-user-list-item-body :item/>
</q-item>
</q-list>
</transition>
</div> </div>
</pn-scroll-list> </div>
</div> <!-- END LEAVE USERS SECTION -->
<pn-small-dialog <!-- BLOCKED USERS SECTION -->
v-model="showDialogBlockUser" <div
icon="mdi-account-remove-outline" v-if="blockedUsers.length!==0"
color="negative" class="flex column items-center w100"
title="users__dialog_block_title" :class="showBlockedUsers ? 'bg-grey-12' : ''"
message1="users__dialog_block_message" >
message2="users__dialog_block_message2" <q-btn
mainBtnLabel="users__dialog_block_ok" class="w100 rotate-icon-btn"
@clickMainBtn="onConfirmBlockUser()" color="grey"
@close="onCancel()" flat
@before-hide="onDialogBeforeHide()" no-caps
/> @click="showBlockedUsers=!showBlockedUsers"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showBlockedUsers }"
>
<span class="text-caption">
{{ !showBlockedUsers
? $t('users__show_blocked_users') + ' (' + blockedUsers.length +')'
: $t('users__hide_blocked_users')
}}
</span>
</q-btn>
<pn-small-dialog <div class="w100" style="overflow: hidden">
v-model="showDialogRestoreUser" <transition
icon="mdi-account-reactivate-outline" appear
color="green" enter-active-class="animated slideInDown"
title="users__dialog_restore_title" leave-active-class="animated slideOutUp"
message1="users__dialog_restore_message" >
message2="users__dialog_restore_message2" <q-list v-if="showBlockedUsers" class="w100">
mainBtnLabel="users__dialog_restore_ok" <q-item
@clickMainBtn="onConfirmRestoreUser()" v-for = "item in blockedUsers"
/> :key="item.id"
clickable
v-ripple
@click="handleUnblockUser(item.id)"
class="w100 text-grey"
>
<pn-user-list-item-body :item/>
</q-item>
</q-list>
</transition>
</div>
</div>
<!-- END BLOCKED USERS SECTION -->
<pn-onboard-btn
v-if="users.length === 0 && usersInit"
icon="mdi-account-outline"
message1="users__onboard_msg1"
message2="users__onboard_msg2"
noBtn
/>
<div
class="column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!usersInit"
>
<q-linear-progress indeterminate />
</div>
<pn-small-dialog
v-model="showDialogBlockUser"
icon="mdi-account-remove-outline"
color="negative"
title="users__dialog_block_title"
message1="users__dialog_block_message"
message2="users__dialog_block_message2"
mainBtnLabel="users__dialog_block_ok"
@clickMainBtn="onConfirmBlockUser()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
<pn-small-dialog
v-model="showDialogRestoreUser"
icon="mdi-account-reactivate-outline"
color="green"
title="users__dialog_restore_title"
message1="users__dialog_restore_message"
message2="users__dialog_restore_message2"
mainBtnLabel="users__dialog_restore_ok"
@clickMainBtn="onConfirmRestoreUser()"
/>
</pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -45,6 +45,7 @@ const routes: RouteRecordRaw[] = [
name: 'chats', name: 'chats',
path: 'chats', path: 'chats',
component: () => import('pages/project-page/ProjectPageChats.vue'), component: () => import('pages/project-page/ProjectPageChats.vue'),
props: true,
meta: { meta: {
backRoute: 'projects' backRoute: 'projects'
} }
@@ -52,6 +53,7 @@ const routes: RouteRecordRaw[] = [
{ {
name: 'users', name: 'users',
path: 'users', path: 'users',
props: true,
component: () => import('pages/project-page/ProjectPageUsers.vue'), component: () => import('pages/project-page/ProjectPageUsers.vue'),
meta: { meta: {
backRoute: 'projects' backRoute: 'projects'
@@ -60,6 +62,7 @@ const routes: RouteRecordRaw[] = [
{ {
name: 'companies', name: 'companies',
path: 'companies', path: 'companies',
props: true,
component: () => import('pages/project-page/ProjectPageCompanies.vue'), component: () => import('pages/project-page/ProjectPageCompanies.vue'),
meta: { meta: {
backRoute: 'projects' backRoute: 'projects'
@@ -138,9 +141,9 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/account/ConsentPage.vue') component: () => import('pages/account/ConsentPage.vue')
}, },
{ {
name: 'change_tariff_rules', name: 'tariffs_info',
path: '/change-tariff-rules', path: '/tariffs-info',
component: () => import('src/pages/account/ChangeTariffRules.vue') component: () => import('pages/account/TariffsInfoPage.vue')
}, },
{ {
name: 'support', name: 'support',

View File

@@ -5,6 +5,7 @@ import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { useChatsStore } from 'stores/chats' import { useChatsStore } from 'stores/chats'
import { useUsersStore } from 'stores/users' import { useUsersStore } from 'stores/users'
import { useCompaniesStore } from 'stores/companies'
export const useSSEStore = defineStore('sse', () => { export const useSSEStore = defineStore('sse', () => {
const eventSource = ref<EventSource | null>(null) const eventSource = ref<EventSource | null>(null)
@@ -16,6 +17,7 @@ export const useSSEStore = defineStore('sse', () => {
const authStore = useAuthStore() const authStore = useAuthStore()
const chatsStore = useChatsStore() const chatsStore = useChatsStore()
const usersStore = useUsersStore() const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
const currentProjectId = computed(() => projectsStore.currentProjectId) const currentProjectId = computed(() => projectsStore.currentProjectId)
@@ -83,12 +85,24 @@ export const useSSEStore = defineStore('sse', () => {
} }
}) })
eventSource.value.addEventListener('customer-update', (event) => { eventSource.value.addEventListener('user-update', (event) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.project_id === currentProjectId.value) { if (data.project_id === currentProjectId.value) {
authStore.updateProfile().catch(() => {}) usersStore.init().catch(() => {})
} }
}) })
eventSource.value.addEventListener('company-update', (event) => {
const data = JSON.parse(event.data)
if (data.project_id === currentProjectId.value) {
usersStore.init().catch(() => {})
companiesStore.init().catch(() => {})
}
})
eventSource.value.addEventListener('customer-update', () => {
authStore.updateProfile().catch(() => {})
})
} }
const eventSourceComputed = computed(() => eventSource.value) const eventSourceComputed = computed(() => eventSource.value)

View File

@@ -3,6 +3,7 @@ interface ProjectParams {
description: string | null description: string | null
logo: string | null logo: string | null
is_logo_bg: boolean | null is_logo_bg: boolean | null
chat_info: string | null
[key: string]: unknown [key: string]: unknown
} }

40
src/utils/colors.ts Normal file
View File

@@ -0,0 +1,40 @@
import { colors } from 'quasar'
function getColorWithOpacity(
color: string,
background: string = '#ffffff',
alpha: number = 0.8
): string {
const clampedAlpha = Math.max(0.01, Math.min(1, alpha))
const hexColor = color.startsWith('#') ? color : colors.getPaletteColor(color)
const hexBackground = background.startsWith('#') ? background : colors.getPaletteColor(background)
if (!hexColor || !hexColor.startsWith('#') || !hexBackground || !hexBackground.startsWith('#')) {
return color
}
const { r: rColor, g: gColor, b: bColor } = colors.hexToRgb(hexColor)
const { r: rBg, g: gBg, b: bBg } = colors.hexToRgb(hexBackground)
const calculateTargetChannel = (colorChannel: number, bgChannel: number): number => {
const target = (colorChannel - bgChannel * (1 - clampedAlpha)) / clampedAlpha
return Math.max(0, Math.min(255, Math.round(target)))
}
const targetChannels = [
calculateTargetChannel(rColor, rBg),
calculateTargetChannel(gColor, gBg),
calculateTargetChannel(bColor, bBg)
]
if (targetChannels.every(channel => channel === 0)) {
return `rgba(${rColor}, ${gColor}, ${bColor}, ${clampedAlpha})`
}
return `rgba(${targetChannels.join(', ')}, ${clampedAlpha})`
}
export {
getColorWithOpacity
}

View File

@@ -30,9 +30,20 @@ const formatNumber = (number: string | number) => {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ") return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")
} }
import type { WebAppUser } from '@twa-dev/types'
const nameTgUser = (name: WebAppUser | undefined) => {
if (!name || !name.first_name) return 'Error'
return name.first_name +
(name.first_name && name.last_name ? ' ' : '') +
name.last_name +
(!(name.first_name || name.last_name) ? name.username : '')
}
export { export {
parseIntString, parseIntString,
removeNullKeys, removeNullKeys,
getFieldValue, getFieldValue,
formatNumber formatNumber,
nameTgUser
} }