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",
"browser-image-compression": "^2.0.2",
"pinia": "^2.0.11",
"quasar": "^2.18.2",
"vue": "^3.4.18",
"quasar": "^2.18.6",
"sass-embedded": "^1.97.2",
"vue": "^3.5.27",
"vue-i18n": "^9.2.2",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@eslint/js": "^9.14.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",
"@twa-dev/types": "^8.0.2",
"@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 pnPageWrapper from 'components/pnPageWrapper.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 pnOverlay from 'components/pnOverlay.vue'
import pnSmallDialog from 'components/pnSmallDialog.vue'
@@ -9,10 +10,12 @@ import pnShadowScroll from 'components/pnShadowScroll.vue'
import pnMagicOverlay from 'components/pnMagicOverlay.vue'
import pnAccountBlockName from 'components/pnAccountBlockName.vue'
import pnOnboardBtn from 'components/pnOnboardBtn.vue'
import pnBottomSheetDialog from 'components/pnBottomSheetDialog.vue'
const components = {
pnPageWrapper,
pnPageCard,
pnScrollList,
pnPageTemplate,
pnAutoAvatar,
pnOverlay,
pnImageSelector,
@@ -20,7 +23,8 @@ const components = {
pnShadowScroll,
pnMagicOverlay,
pnAccountBlockName,
pnOnboardBtn
pnOnboardBtn,
pnBottomSheetDialog
}
export default boot(({ app }) => {

View File

@@ -10,12 +10,43 @@ declare global {
}
export default defineBoot(({ app }) => {
if (window.Telegram?.WebApp) {
const webApp = window.Telegram.WebApp
webApp.ready()
sessionStorage.setItem('isTelegram', webApp.initData ? 'true' : 'false')
webApp.SettingsButton.isVisible = true
app.config.globalProperties.$tg = webApp
app.provide('tg', webApp)
const tg = window.Telegram?.WebApp
tg.ready()
sessionStorage.setItem('isTelegram', tg.initData ? 'true' : 'false')
const platform = tg.platform.toLowerCase()
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>
<div
class="flex row items-center no-wrap logo-component"
>
<img
src="src/assets/logo.svg"
style="height: 24px; max-width: 24px; padding-right: 2px;"
<div class="flex row items-center no-wrap logo-component">
<LogoIcon
v-bind="$attrs"
class="logo-svg"
:class="{
'is-animated': animated,
'hide-bg': !withBackground
}"
:style="{ color: iconColor }"
/>
<span
class="text-brand"
style="margin-right: 0.05em;"
>
tg
</span>
<span class="text-brand text-bold">
Crew
</span>
<div :class="'text-' + textColor" class="logo-text q-ml-xs">
<span>tg</span>
<span class="text-bold">Crew</span>
</div>
</div>
</template>
<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>
<style lang="scss" scoped>
.logo-component {
svg {
width: 1em;
height: 1em;
margin-right: 0.125em;
.logo-svg {
height: 24px;
width: 24px;
:deep(.logo-bg) {
transition: opacity 0.3s;
}
span {
line-height: 1;
&.hide-bg :deep(.logo-bg) {
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 {
fill: $brand;
}
@keyframes blink {
100%,
0% {
fill: $brand2;
}
60% {
fill: $brand2;
opacity: 0.8;
}
}
#path5 {
animation: blink 3s infinite;
@keyframes expand-r { 50% { r: 8px; } }
@keyframes o { 50% { transform: var(--t); } }
@keyframes draw {
0%, 100% { stroke-dashoffset: 0; }
50% { stroke-dashoffset: 1; }
}
</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 emit = defineEmits(['agree'])
const router = useRouter()
</script>

View File

@@ -1,9 +1,9 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{ $t(title) }}
</template>
<template #footer>
<template #card-actions>
<q-btn
rounded color="primary"
class="w100 fix-disabled-btn"
@@ -13,7 +13,7 @@
{{ $t(btnText) }}
</q-btn>
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm w100">
<slot name="myCompany"/>
@@ -57,8 +57,7 @@
</q-input>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>
<script setup lang="ts">

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
<template>
<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
v-if="showCalendarBtn"
flat round
:color="calendarActive ? 'primary' : 'grey'"
round
:color="calendarActive ? 'primary' : 'grey-2'"
:class="calendarActive ? 'text-white' : 'text-grey'"
class="q-mr-sm"
@click="emit('toggle-calendar')"
>
<div>
@@ -21,6 +23,7 @@
</div>
</q-btn>
<div class="w100">
<q-input
v-model="search"
clearable
@@ -29,18 +32,21 @@
filled
:placeholder="$t(placeholder)"
dense
class="col-grow q-mx-sm"
class="rounded-fix bg-white shadow-1"
>
<template #prepend>
<q-icon name="mdi-magnify" color="grey"/>
</template>
</q-input>
</div>
<q-btn
v-if="showFilterBtn"
@click="emit('open-filters')"
flat round
:color="filterActive ? 'primary' : 'grey'"
round
:color="filterActive ? 'primary' : 'grey-2'"
:class="filterActive ? 'text-white' : 'text-grey'"
class="q-ml-sm"
>
<div>
<q-icon name="mdi-filter-outline" size="sm"/>

View File

@@ -1,98 +1,125 @@
<template>
<q-dialog
v-model="modelValue"
maximized
transition-show="slide-up"
transition-hide="slide-down"
position="bottom"
>
<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="
border-top-left-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
ref="cardHeaderRef"
class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md"
>
<div class="text-h6 q-mx-xs ellipsis">{{ $t(title) }}</div>
<div ref="cardHeaderRef" class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md">
<q-resize-observer @resize="onHeaderResize" />
<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 class="flex items-center justify-between no-wrap">
<slot name="btnSlot">
<q-btn
icon="mdi-close"
@click="modelValue = false"
flat round
/>
<q-btn icon="mdi-close" @click="modelValue = false" flat round />
</slot>
</div>
</div>
<div
ref="cardBodyRef"
class="q-px-none q-ma-none"
>
<div class="q-px-none q-ma-none">
<pn-shadow-scroll
:hideShadows="false"
:height="bodyHeight"
:bottomOffset
:topOffset
>
<div ref="cardBodyInnerRef" class="q-pa-none q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<div class="q-pa-none q-ma-none">
<q-resize-observer @resize="onInnerBodyResize" />
<slot />
</div>
</pn-shadow-scroll>
</div>
<div
style="overflow: hidden"
:style="{ height: footerHeight + 'px'}"
>
<div ref="cardFooterInnerRef" class="q-pa-md">
<q-resize-observer @resize="updateDimensions" />
<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>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useSlots } from 'vue'
import { ref, computed, onMounted, onUnmounted, useSlots } from 'vue'
const modelValue = defineModel<boolean>({
required: true
})
defineProps<{
const modelValue = defineModel<boolean>({ required: true })
const props = withDefaults(defineProps<{
title: string
}>()
caption?: string
maximized?: boolean
}>(), { maximized: true })
const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null)
const cardFooterInnerRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const cardRef = ref()
const headerHeight = ref(0)
const footerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const tgInsetBottom = ref(0)
const topOffset = 16
const updateDimensions = async () => {
await nextTick(() => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0
footerHeight.value = cardFooterInnerRef.value?.offsetHeight || 0
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
const needScroll = headerHeight.value + footerHeight.value + bodyInnerHeight.value > window.innerHeight - 48
bodyHeight.value = needScroll
? window.innerHeight - 48 - headerHeight.value - footerHeight.value
: bodyInnerHeight.value + 16
const windowHeight = ref(window.innerHeight)
const hasFooter = computed(() => !!slots['footer'])
const bottomOffset = computed(() => hasFooter.value ? footerHeight.value : tgInsetBottom.value)
const bodyHeight = computed(() => {
// hack for update computed params on window resize
const _trigger = windowHeight.value //eslint-disable-line
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' })
watch(modelValue, updateDimensions)
const getTgInsetBottom = () => {
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>

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

@@ -26,71 +26,19 @@
</q-item-section>
</q-item>
<q-dialog
<pn-bottom-sheet-dialog
:title
:caption
v-model="showDialog"
maximized
transition-show="slide-up"
transition-hide="slide-down"
position="bottom"
>
<q-card
class="fix-card-width flex column no-scroll no-wrap q-px-none"
style="
border-top-left-radius: var(--top-radius) !important;
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" />
<div class="q-px-md">
<slot/>
</div>
</pn-shadow-scroll>
</div>
<div
ref="cardFooterRef"
class="q-pa-md"
>
<slot name="footer"/>
</div>
</q-card>
</q-dialog>
</pn-bottom-sheet-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useSlots } from 'vue'
import { ref } from 'vue'
defineProps<{
title: string
@@ -100,31 +48,7 @@
}>()
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>
<style scoped>
.fix-card-width {
width: var(--body-width) !important;
}
</style>

View File

@@ -9,7 +9,7 @@
<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 }}
{{ $t(message1) }}
</div>
</div>
</q-btn>
@@ -17,13 +17,13 @@
<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 }}
{{ $t(message1) }}
</div>
</div>
</div>
<div v-if="message2" class="text-caption text-center">
{{ message2 }}
{{ $t(message2) }}
</div>
</div>
</div>
@@ -44,5 +44,6 @@
}
</script>
<style>
<style scoped>
</style>

View File

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

View File

@@ -1,24 +1,117 @@
<template>
<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"
class="w100 col-grow relative-position top-rounded-card bg-white overflow-hidden"
>
<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/>
<div v-if="hasFooter" class="flex w100 q-pa-md bg-white">
<slot name="footer"/>
</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>
<slot v-if="hasTabs" name="tabs"/>
</template>
<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 hasFooter = computed(() => !!slots.footer)
const hasTabs = computed(() => !!slots.tabs)
const hasHeader = computed(() => !!slots['card-header'])
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>
<style>
<style scoped>
</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>
<div :class="{'fix-scroll-area-content': hideShadows }" class="fix-scroll-area-content-width column col col-grow">
<q-scroll-area
ref="scrollAreaRef"
:style="{ height: height + 'px' }"
class="w100 q-pa-none q-ma-none column col col-grow"
:style="scrollStyle"
:vertical-offset="[top, bottom]"
:thumb-style="{ borderRadius: '3px', background: '#999', width: '6px', opacity: '0.4' }"
@scroll="onScroll"
:class=" {
'shadow-top': hasScrolled,
'shadow-bottom': hasScrolledBottom
}"
class="shadow-scroll w100"
>
<q-resize-observer @resize="onInnerResize"/>
<div :style="{ height: top + 'px'}"/>
<slot/>
<div :style="{ height: bottom + 'px'}"/>
</q-scroll-area>
</div>
</template>
<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
hideShadows: boolean
topOffset?: number
bottomOffset?: number
scrollPosition?: number
}>()
const emit = defineEmits(['updateScrollPosition'])
const hasScrolled = ref(false)
const hasScrolledBottom = ref(false)
interface ScrollInfo {
verticalPosition: number;
verticalPercentage: number;
verticalSize: number;
verticalContainerSize: number;
offset?: number
}>(),
{
scrollPosition: 0,
offset: 16
}
)
function onScroll (info: ScrollInfo) {
hasScrolled.value = info.verticalPosition > 0
const scrollEnd = info.verticalPosition + info.verticalContainerSize >= info.verticalSize - 1
hasScrolledBottom.value = !scrollEnd
const emit = defineEmits([
'updateScrollPosition',
'resizeScrollArea'
])
const top = computed(() => props.topOffset || props.offset)
const bottom = computed(() => props.bottomOffset || props.offset)
const innerHeight = ref(0)
const scrollStyle = computed(() => ({
height: props.height + 'px',
'--top-height': top.value + 'px',
'--bottom-height': bottom.value + 'px'
}))
function onScroll () {
emit('updateScrollPosition', scrollAreaRef.value?.getScrollPosition().top || 0)
}
const scrollAreaRef = ref()
onActivated(() => {
scrollAreaRef.value?.setScrollPosition('vertical', props?.scrollPosition || 0)
})
watch(() => props.scrollPosition, (newPosition) => {
watch(() => props.scrollPosition, async (newPosition) => {
await nextTick()
scrollAreaRef.value?.setScrollPosition('vertical', newPosition)
}, { 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>
<style scoped>
.q-scrollarea {
position: relative;
transform: translateY(0);
}
.q-scrollarea::before {
content: '';
.shadow-scroll:before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(to bottom,
rgba(0,0,0,0.12) 0%,
rgba(0,0,0,0.08) 50%,
transparent 100%
);
width: 100%;
height: var(--top-height);
background: linear-gradient(white, transparent);
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;
z-index: 95;
}
.q-scrollarea::after {
content: '';
.shadow-scroll:after {
content: "";
position: absolute;
bottom: 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%;
height: var(--bottom-height);
background: linear-gradient(transparent, white);
pointer-events: none;
z-index: 95;
}
.fix-scroll-area-content:deep(.q-scrollarea::before) {
content: none;
}
.fix-scroll-area-content:deep(.q-scrollarea::after) {
content: none;
}
.q-scrollarea.shadow-top::before {
opacity: 1;
transform: translateY(0);
}
.q-scrollarea.shadow-bottom::after {
opacity: 1;
transform: translateY(0);
.shadow-scroll :deep(.q-scrollarea__content) {
max-width: min(100vw, var(--body-width));
}
</style>

View File

@@ -1,10 +1,18 @@
<template>
<q-dialog v-model="modelValue" class="q-ma-none">
<q-card
class="q-pa-none q-ma-none w100 no-scroll rounded-card"
style="max-height: calc(100vh - 48px);"
<q-dialog
v-model="modelValue"
class="q-ma-none"
>
<q-card-section class="row items-center q-pb-none">
<q-card
class="q-pa-none q-ma-none w100 q-pb-md no-scroll rounded-card no-wrap overflow-hidden"
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-resize-observer @resize="onResizeCard" />
<q-card-section class="row items-center q-pb-none" ref="cardHeaderRef">
<div class="flex no-wrap items-center w100">
<q-resize-observer @resize="onHeaderResize"/>
<span class="text-bold">
@@ -15,10 +23,15 @@
</div>
</q-card-section>
<div>
<pn-shadow-scroll :height="scrollAreaHeight" :hideShadows="false">
<div>
<q-resize-observer @resize="onBodyResize"/>
<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>
@@ -29,44 +42,60 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import pnShadowScroll from './pnShadowScroll.vue'
defineProps<{
title: string
}>()
import { useSlots } from 'vue'
const modelValue = defineModel<boolean>({
required: true
})
defineProps<{
title: string
}>()
const slots = useSlots()
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const scrollAreaHeight = ref(0)
const cardHeight = ref(0)
interface sizeParams {
height: number,
width: number
}
function onResizeCard (size :sizeParams) {
cardHeight.value = size.width
}
function onHeaderResize (size: sizeParams) {
headerHeight.value = size.height
}
function onBodyResize (size: sizeParams) {
bodyHeight.value = size.height
}
async function updateScrollAreaHeight () {
const updateDimensions = async () => {
await nextTick(() => {
scrollAreaHeight.value = Math.min(
bodyHeight.value + 16,
window.innerHeight - headerHeight.value - 64
)
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
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(headerHeight, updateScrollAreaHeight)
watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
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>

View File

@@ -1,9 +1,9 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{ $t(title) }}
</template>
<template #footer>
<template #card-actions>
<q-btn
rounded color="primary"
class="w100 fix-disabled-btn"
@@ -14,7 +14,6 @@
</q-btn>
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm w100">
<pn-image-selector
v-model="projectMod.logo"
@@ -52,16 +51,28 @@
filled
autogrow
no-error-icon
class="w100 q-pt-sm"
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-input
v-model="projectMod.chat_info"
dense
filled
autogrow
no-error-icon
class="w100 q-pt-sm"
:label="$t('project_block__project_chat_info')"
:rules="[inputRules.longText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>
@@ -84,6 +95,7 @@
const projectMod = reactive({
name: props.project?.name ?? '',
description: props.project?.description ?? '',
chat_info: props.project?.chat_info ?? '',
logo: props.project?.logo ?? null
})
@@ -94,6 +106,7 @@
const outData = {
name: projectMod.name,
description: getFieldValue(projectMod.description, props.project?.description),
chat_info: getFieldValue(projectMod.chat_info, props.project?.chat_info),
logo: projectMod.logo !== '' ? projectMod.logo : null
}
emit('update', removeNullKeys(outData, ['logo']))

View File

@@ -69,6 +69,7 @@
title="subscribe__dialog_select_tariff_title"
v-model="showTarfiffSelectDialog"
>
<div class="w100 column">
<q-banner
v-if="!currentTariff.can_change"
@@ -103,12 +104,13 @@
<div
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') }}
</div>
</div>
</div>
<template #footer>
<div class="column">
<q-btn
@@ -149,7 +151,6 @@
import telegramStar from 'components/TelegramStar.vue'
import pnSubscribeTariffs from 'components/subscribe/pnSubscribeTariffs.vue'
import pnSubscribePeriods from 'components/subscribe/pnSubscribePeriods.vue'
import pnBottomSheetDialog from 'components/pnBottomSheetDialog.vue'
import {
calcChangeTariff,
changeTariff,
@@ -176,6 +177,7 @@
const selectedTariffPrice = computed(() => props.tariffs.find((el: Tariff) => el.id === props.currentTariff?.id)?.price ?? 0)
const showTarfiffSelectDialog = ref(false)
async function clickChangeTariff () {
if (props.currentTariff &&
selectedTariff.value &&
@@ -196,6 +198,7 @@
description: 'subscribe__dialog_select_tariff_warn_once_day_msg_2'
}
}
return {}
})
const activePeriodMessage = ref('')
@@ -218,7 +221,7 @@
const renew = await calcRenewTariff(props.currentTariff.id, selectedPeriod.value)
const textBtn = t('subscribe__pay_renew') + ' ⭐' + formatNumber(renew.amount) +
' (' + 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 })
}

View File

@@ -1,9 +1,9 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{ $t(title) }}
</template>
<template #footer>
<template #card-actions>
<q-btn
rounded color="primary"
class="w100 fix-disabled-btn"
@@ -13,7 +13,7 @@
{{ $t(btnText) }}
</q-btn>
</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
@@ -148,8 +148,7 @@
</q-input>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>

View File

@@ -68,10 +68,6 @@ body {
margin-right: calc(max((100vw - var(--body-width))/2, 0px) + 18px) !important;
}
.projects-header {
background-color: #eee;
}
.top-rounded-card {
border-top-left-radius: var(--top-radius) !important;
border-top-right-radius: var(--top-radius) !important;
@@ -81,8 +77,9 @@ body {
border-radius: var(--top-radius) !important;
}
.glass-card {
background-color: rgb(255, 255, 255, 0.91) !important;
.rounded-fix {
border-radius: 1000px;
overflow: hidden;
}
.orline {
@@ -105,6 +102,7 @@ body {
.fix-disabled-btn.q-btn[disabled]:not(.q-btn--flat) {
background-color: $grey-5 !important;
opacity: 1 !important;
}
.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
class="main-content q-pa-none q-ma-none no-scroll bg-transparent"
>
<q-page class="no-scroll column">
<q-page>
<router-view />
</q-page>
</q-page-container>

View File

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

View File

@@ -1,9 +1,9 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{$t('account_change_email__title')}}
</template>
<pn-scroll-list>
<q-stepper
v-model="step"
vertical
@@ -185,8 +185,7 @@
message2="account_change_email__ok_message2"
route-name="account"
/>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>
<script setup lang="ts">

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
<pn-account-block-name/>
<q-btn
@@ -10,8 +10,7 @@
/>
</template>
<pn-scroll-list>
<q-list separator>
<q-list>
<q-item
v-for="item in displayItems"
:key="item.id"
@@ -38,8 +37,6 @@
</q-item-section>
</q-item>
</q-list>
</pn-scroll-list>
</pn-page-card>
<pn-small-dialog
v-model="showStopUsingDialog"
@@ -51,6 +48,7 @@
mainBtnLabel="account__stop_using_dialog_btn_ok"
@clickMainBtn="onConfirmStopUsing"
/>
</pn-page-template>
</template>
<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: 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: 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(() => (

View File

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

View File

@@ -1,9 +1,10 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{ $t('chat_card__title') }}
</template>
<template #footer>
<template #card-actions>
<q-btn
rounded color="primary"
class="w100"
@@ -14,7 +15,6 @@
</q-btn>
</template>
<pn-scroll-list>
<div
v-if="chat"
class="flex column items-center q-px-md q-pt-md q-pb-sm"
@@ -49,7 +49,7 @@
/>
</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>
{{ $t('chat_card__members') + ' (' + chatUsers.length +')' }}
</q-item-label>
@@ -71,8 +71,7 @@
</pn-user-list-item-body>
</q-item>
</q-list>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>

View File

@@ -1,10 +1,10 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{$t('mask__title')}}
</template>
<template #footer>
<template #card-actions>
<q-btn
rounded color="primary"
class="w100 fix-disabled-btn"
@@ -14,15 +14,15 @@
</q-btn>
</template>
<pn-scroll-list>
<template #card-body-header>
<div class="q-ma-md flex no-wrap items-center">
<template #card-header>
<div class="q-pa-md flex no-wrap items-center bg-grey-2">
<q-icon name="mdi-information q-pr-sm" size="sm" color="primary"/>
<span class="text-caption">
{{ $t('mask__info_block')}}
</span>
</div>
</template>
<q-table
flat
:rows="displayCompanies"
@@ -138,7 +138,6 @@
</template>
</q-table>
<div class="q-py-none flex column w100" style="min-height: 18px"/>
</pn-scroll-list>
<q-dialog v-model="showDialogHelp">
<q-card class="q-ma-sm w100 rounded-card">
@@ -156,7 +155,7 @@
</q-card-section>
</q-card>
</q-dialog>
</pn-page-card>
</pn-page-template>
</template>
<script setup lang="ts">

View File

@@ -5,16 +5,23 @@
<q-scroll-area
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;"
<div
class="w100 flex justify-center items-center"
style="min-height: 100vh;"
>
<pn-glass
id="login_block"
class="rounded-card flex items-center q-pa-lg q-my-md shadow-1"
style="max-width: 300px;"
>
<div class="flex column no-wrap items-center">
<div class="q-pb-lg">
<base-logo
class="col-grow q-pa-lg text-h5"
withBackground
animated
class="text-h5"
/>
</div>
<div class="flex column w100">
<q-input
@@ -106,27 +113,23 @@
flat rounded
sm no-caps
color="primary"
class="q-my-md w100"
class="q-mt-md w100"
@click="handleTelegramLogin"
>
<div class="flex items-center text-primary">
<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>
{{
tgUser?.first_name +
(tgUser?.first_name && tgUser?.last_name ? ' ' : '') +
tgUser?.last_name +
(!(tgUser?.first_name || tgUser?.last_name) ? tgUser?.username : '')
}}
<span class="ellipsis">
{{ nameTgUser(tgUser) }}
</span>
</div>
</q-btn>
</div>
</q-card>
</div>
</pn-glass>
</div>
</q-scroll-area>
</q-page>
@@ -138,11 +141,13 @@
import baseLogo from 'components/BaseLogo.vue'
import { useInputRules } from 'composables/useInputRules'
import { useAuthStore } from 'stores/auth'
import type { WebApp } from '@twa-dev/types'
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 tgUser = tg.initDataUnsafe.user
const tgUser = tg.initDataUnsafe.user as WebAppUser | undefined
const authStore = useAuthStore()
const router = useRouter()

View File

@@ -1,124 +1,203 @@
<template>
<pn-page-card>
<pn-page-wrapper>
<template #title>
<project-page-header/>
</template>
<q-tab-panels
id="tab-panels"
v-model="tabSelect"
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
v-for="tab in tabs"
:key="tab.name"
:name="tab.name"
class="q-pa-none flex column col-grow no-scroll"
style="flex-grow: 2"
class="q-pa-none column w100"
>
<router-view/>
<router-view :tabsSlotHeight/>
</q-tab-panel>
</q-tab-panels>
<template #tabs>
<q-footer class="bg-grey-1 text-grey main-content">
<div class="q-pa-none q-ma-none">
<q-resize-observer @resize="onTabsSlotResize"/>
<div class="q-py-md q-px-md overflow-hidden rounded-fix">
<div style="height: 0px;" class="row no-wrap no-pointer-events overflow-hidden">
<div
v-for="(tab, idx) in tabs"
:key="idx"
class="text-caption"
>
<q-resize-observer @resize="val => onTabResize(val, idx)"/>
{{ $t(tab.label) }}
</div>
</div>
<q-tabs
style = "z-index: 1000"
v-model="tabSelect"
dense
align="justify"
align="center"
id="tabs"
:breakpoint="0"
class="w100 text-grey-7 bg-grey-2 shadow-1 rounded-fix"
style="z-index: 1000;"
:style="minWidthTab"
>
<q-route-tab
v-for="tab in tabs"
:to="tab.to"
:to="{ name: tab.name }"
:name="tab.name"
:key="tab.name"
:ripple="false"
no-caps
dense
:class="tabSelect === tab.name ? 'active' : ''"
class="flex column w100"
:class="(tabSelect === tab.name ? 'active' : '')"
>
<template #default>
<div class="flex column items-center">
<q-icon :name="tab.icon" :size="maxTabWidth < baseTabWidth ? 'sm' : 'md'"/>
<div
class="text-caption flex justify-center"
:style="{ width: (baseTabWidth - 32) + 'px'}"
v-if="maxTabWidth < baseTabWidth"
<div class="column items-center">
<q-icon :name="tab.icon" :size="config?.icon"/>
<span
v-if="config?.showLabel"
class="text-caption"
>
<span>
<q-resize-observer @resize="size => tab.width = size.width"/>
{{ $t(tab.label) }}
</span>
</div>
</div>
</template>
</q-route-tab>
<q-resize-observer @resize="size => tabsWidth = size.width" />
</q-tabs>
</q-footer>
<div class="bg-transparent"
style="height: var(--tg-safe-area-inset-bottom)"
/>
</div>
</div>
</template>
</pn-page-card>
</pn-page-wrapper>
</template>
<script setup lang="ts">
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 { useRoute } from 'vue-router'
const route = useRoute()
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
const chatsQty = computed(() => chatsStore.chats.length)
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 tabs = [
{ name: 'chats', label: 'project__chats', icon: 'mdi-chat-outline' },
{ name: 'users', label: 'project__users', icon: 'mdi-account-outline' },
{ name: 'companies', label: 'project__companies', icon: 'mdi-account-group-outline' }
] as const
const tabSelect = ref<string>()
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
})
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>
<style>
.active {
<style scoped lang="scss">
#tabs :deep(.q-tab) {
padding: 0 var(--current-gap);
}
#tabs :deep(.active) {
color: var(--q-primary)
}
.tab-panel-color {
background: transparent
#tabs :deep(.q-router-link--active .q-tab__indicator) {
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 {
display: flex;
flex-direction: column;
flex-grow: 2;
width: 100%
#tabs :deep(.q-focus-helper) {
border-radius: calc(var(--tabs-height) / 2);
border: 0px;
}
</style>
#tabs :deep(.q-tabs__arrow) {
display: none;
}
<style scoped>
#tabs :deep(.q-tab__indicator) {
height: 3px !important;
#tabs :deep(.q-tab__content) {
min-width: var(--min-width-tab);
}
#tabs :deep(.q-tab--active .q-tab__indicator) {
width: var(--active-tab-width);
right: inherit;
left: inherit;
}
</style>

View File

@@ -1,7 +1,9 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
<div class="q-pr-md ellipsis">
{{ $t('projects__title') }}
</div>
<q-btn
@click="goAccount"
flat rounded
@@ -14,25 +16,16 @@
<pn-account-block-name/>
</q-btn>
</template>
<pn-scroll-list
:hideHeader="projects.length === 0 && archiveProjects.length === 0"
>
<template #card-body-header>
<q-input
<template #card-header>
<pn-action-bar
v-model="searchProject"
clearable
clear-icon="close"
: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>
placeholder="projects__search"
:show-calendar-btn="false"
:show-filter-btn="false"
/>
</template>
<q-list separator v-if="projects.length !== 0">
<q-list v-if="projects.length !== 0">
<template
v-for="item in activeProjects"
:key="item.id"
@@ -125,7 +118,7 @@
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showArchive" class="w100">
<q-list v-if="showArchive" class="w100">
<q-item
v-for = "item in archiveProjects"
:key="item.id"
@@ -160,37 +153,34 @@
<pn-onboard-btn
v-if="projects.length===0 && projectsInit"
icon="mdi-briefcase-plus-outline"
:message1="$t('projects__lets_start')"
:message2="$t('projects__lets_start_description')"
message1="projects__lets_start"
message2="projects__lets_start_description"
@btn-click="createNewProject"
/>
<div
class="flex column justify-center items-center w100"
class="column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!projectsInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
<q-page-sticky
position="bottom-right"
:offset="[0, 18]"
class="fix-fab-offset"
>
<template #card-fab>
<transition
appear
enter-active-class="animated zoomIn"
>
<pn-glass-btn type="fab" color="primary">
<q-btn
fab
icon="add"
color="brand"
color="transparent"
@click="createNewProject"
/>
</pn-glass-btn>
</transition>
</q-page-sticky>
</pn-page-card>
</template>
</pn-page-template>
<pn-small-dialog
v-model="showDialogArchiveProject"
@@ -221,6 +211,8 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectsStore } from 'stores/projects'
import pnGlassBtn from 'components/pnGlassBtn.vue'
import pnActionBar from 'components/pnActionBar.vue'
import type { Project } from 'types/Project'
const router = useRouter()

View File

@@ -1,5 +1,8 @@
<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>
<script setup lang="ts">

View File

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

View File

@@ -1,11 +1,10 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{ $t('settings__title') }}
</template>
<pn-scroll-list>
<q-list separator>
<q-list>
<q-item-label header>{{ $t('settings__software_title') }}</q-item-label>
<pn-item-btm-dialog
@@ -80,8 +79,7 @@
</pn-item-btm-dialog>
</template>
</q-list>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>
<script setup lang="ts">

View File

@@ -1,14 +1,14 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{$t('subscribe__title')}}
</template>
<pn-scroll-list>
<template #card-body-header>
<template #card-header>
<div
v-if="tariffs && currentTariff"
id="subscribe-current-balance"
class="flex w100 q-px-md q-py-md row"
class="flex w100 q-px-md q-py-md row bg-grey-11"
>
<div class="flex w100 justify-between items-center no-wrap">
@@ -52,6 +52,7 @@
</div>
</div>
</template>
<div v-if="tariffs && currentTariff">
<subscribe-upgrade
v-if="isCurrentTariffTest"
@@ -68,8 +69,7 @@
@update="prepareTgBtn"
/>
</div>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>
<script setup lang="ts">

View File

@@ -1,10 +1,9 @@
<template>
<pn-page-card>
<pn-page-template>
<template #title>
{{ $t('support__title') }}
</template>
<pn-scroll-list>
<div class="q-pa-md flex column w100 items-center">
<div align="center" >
<div>{{ $t('support__work_time_text') }}</div>
@@ -33,8 +32,7 @@
{{ supportEmailAddress }}
</div>
</div>
</pn-scroll-list>
</pn-page-card>
</pn-page-template>
</template>
<script setup lang="ts">

View File

@@ -1,5 +1,8 @@
<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>
<script setup lang="ts">

View File

@@ -1,10 +1,8 @@
<template>
<div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list
:hideHeader="chats.length === 0"
>
<template #card-body-header>
<pn-page-card v-bind="$attrs">
<template #card-header>
<pn-action-bar
v-if="chats.length !== 0"
v-model="search"
placeholder="chats__search"
:show-calendar-btn="false"
@@ -12,27 +10,7 @@
/>
</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-list v-if="chats.length !== 0">
<q-slide-item
v-for="item in displayChats"
:key="item.id"
@@ -97,7 +75,7 @@
</q-list>
<div
v-if="obsoleteChats.length !== 0"
v-if="displayObsoleteChats.length !== 0"
class="flex column items-center w100"
:class="showObsoleteChats ? 'bg-grey-12' : ''"
>
@@ -112,7 +90,7 @@
>
<span class="text-caption">
{{ !showObsoleteChats
? $t('chats__show_obsolete_chats') + ' (' + obsoleteChats.length +')'
? $t('chats__show_obsolete_chats') + ' (' + displayObsoleteChats.length +')'
: $t('chats__hide_obsolete_chats')
}}
</span>
@@ -124,9 +102,9 @@
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showObsoleteChats" class="w100">
<q-list v-if="showObsoleteChats" class="w100">
<q-item
v-for = "item in obsoleteChats"
v-for = "item in displayObsoleteChats"
:key="item.id"
class="w100 text-grey"
>
@@ -168,58 +146,71 @@
<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"
message1="chats__onboard_msg1"
message2="chats__onboard_msg2"
@click="showAttachChatDialog=true; fabState=true"
/>
<div
class="flex column justify-center items-center w100"
class="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
:style="{ zIndex: !showOverlay ? 'inherit' : '5100 !important' }"
position="bottom-right"
:offset="[0, 18]"
class="fix-fab-offset"
>
<template #card-fab>
<div v-if="chats.length !== 0 || obsoleteChats.length !== 0">
<transition
appear
enter-active-class="animated zoomIn"
>
<q-fab
v-model="fabState"
v-if="showFab"
icon="add"
color="brand"
:class="isEnableChatAdd ? '' : 'fab-disabled'"
direction="up"
vertical-actions-align="right"
@click="showOverlay = !showOverlay"
:disable="!tg.initData"
>
<q-fab-action
square
clickable
v-ripple
class="bg-white change-fab-action q-pa-none"
style="cursor: default"
label-style=""
<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>
<template #icon>
<q-list class="q-py-sm" v-if="isEnableChatAdd">
<transition
appear
enter-active-class="animated zoomIn"
>
<pn-glass-btn
type="fab"
color="primary"
>
<q-btn
@click="showAttachChatDialog = !showAttachChatDialog"
fab
icon="add"
color="transparent"
:disable="!tg.initData"
/>
</pn-glass-btn>
</transition>
</template>
<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
@click="item.type !== 'header' && item.func ? item?.func() : () => {}"
: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' : ''">
@@ -250,6 +241,7 @@
</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') }}
@@ -258,14 +250,7 @@
{{ $t('chats__disable_add_chat_description') }}
</span>
</div>
</template>
</q-fab-action>
</q-fab>
</transition>
</q-page-sticky>
</div>
<pn-overlay v-if="showOverlay"/>
</pn-simple-scroll-dialog>
<pn-small-dialog
v-model="showDialogDeleteChat"
@@ -285,7 +270,7 @@
title="chat__help_title"
>
<div class="q-py-none q-px-md q-ma-none">
<q-list class="q-pt-sm">
<q-list>
<pn-chat-type-item :access="2"/>
<pn-chat-type-item :access="1"/>
<pn-chat-type-item :state="2"/>
@@ -306,21 +291,25 @@
</div>
</div>
</pn-simple-scroll-dialog>
<template #overlay>
<pn-overlay v-if="showAttachChatDialog" style="z-index:1005;"/>
</template>
</pn-page-card>
</template>
<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 { useChatsStore } from 'stores/chats'
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 { date } from 'quasar'
import { useRouter, useRoute } from 'vue-router'
import pnChatTypeItem from 'components/pnChatTypeItem.vue'
import pnSimpleScrollDialog from 'components/pnSimpleScrollDialog.vue'
import pnActionBar from 'components/pnActionBar.vue'
import pnGlassBtn from 'components/pnGlassBtn.vue'
import { chatAccess, chatState } from 'utils/chats'
const router = useRouter()
@@ -329,7 +318,7 @@
const { t } = useI18n()
const search = ref('')
const showOverlay = ref<boolean>(false)
const showAttachChatDialog = ref<boolean>(false)
const chatsStore = useChatsStore()
const showDialogDeleteChat = ref<boolean>(false)
const deleteChatId = ref<number | undefined>(undefined)
@@ -444,28 +433,6 @@
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) {
return new Date(d * 1000).getFullYear() === new Date(Date.now()).getFullYear()
? date.formatDate(d * 1000, 'DD MMM')
@@ -475,31 +442,19 @@
const obsoleteChats = computed(() => chatsStore.obsoleteChats)
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>
<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 */
:deep(.q-slide-item__right)
{

View File

@@ -1,33 +1,6 @@
<template>
<div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list
hideHeader
>
<template #card-body-btn>
<div class="flex items-center justify-end q-pa-sm w100" v-if="companies.length !== 0">
<q-btn
: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">
<pn-page-card v-bind="$attrs">
<q-list v-if="companies.length !== 0">
<q-slide-item
v-for="item in displayCompanies"
:key="item.id"
@@ -97,38 +70,56 @@
<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')"
message1="companies__onboard_msg1"
message2="companies__onboard_msg2"
@btn-click="createCompany"
/>
<div
class="flex column justify-center items-center w100"
class="column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!companiesInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
<template #card-fab>
<q-page-sticky
position="bottom-right"
:offset="[0, 18]"
class="fix-fab-offset"
>
<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
appear
enter-active-class="animated zoomIn"
>
<pn-glass-btn
type="fab"
color="brand"
>
<q-btn
v-if="showFab"
fab
icon="add"
color="brand"
color="transparent"
@click="createCompany()"
/>
</pn-glass-btn>
</transition>
</q-page-sticky>
</div>
</template>
<pn-small-dialog
v-model="showDialogDeleteCompany"
@@ -142,13 +133,15 @@
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useCompaniesStore } from 'stores/companies'
import { useUsersStore } from 'stores/users'
import PnGlassBtn from 'components/pnGlassBtn.vue'
const router = useRouter()
const route = useRoute()
@@ -222,28 +215,6 @@
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>
<style scoped>

View File

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

View File

@@ -1,10 +1,8 @@
<template>
<div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list
:hideHeader="users.length === 0"
>
<template #card-body-header>
<pn-page-card v-bind="$attrs">
<template #card-header>
<pn-action-bar
v-if="users.length !== 0"
v-model="search"
placeholder="users__search"
:show-calendar-btn="false"
@@ -12,7 +10,7 @@
/>
</template>
<q-list separator v-if="users.length !== 0">
<q-list v-if="users.length !== 0">
<q-slide-item
v-for="item in displayUsers"
:key="item.id"
@@ -63,7 +61,7 @@
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showLeaveUsers" class="w100">
<q-list v-if="showLeaveUsers" class="w100">
<q-item
v-for = "item in leaveUsers"
:key="item.id"
@@ -106,7 +104,7 @@
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showBlockedUsers" class="w100">
<q-list v-if="showBlockedUsers" class="w100">
<q-item
v-for = "item in blockedUsers"
:key="item.id"
@@ -126,19 +124,17 @@
<pn-onboard-btn
v-if="users.length === 0 && usersInit"
icon="mdi-account-outline"
:message1="$t('users__onboard_msg1')"
:message2="$t('users__onboard_msg2')"
message1="users__onboard_msg1"
message2="users__onboard_msg2"
noBtn
/>
<div
class="flex column justify-center items-center w100"
class="column justify-center items-center w100"
style="position: absolute; bottom: 0;"
v-if="!usersInit"
>
<q-linear-progress indeterminate />
</div>
</pn-scroll-list>
</div>
<pn-small-dialog
v-model="showDialogBlockUser"
@@ -164,6 +160,7 @@
@clickMainBtn="onConfirmRestoreUser()"
/>
</pn-page-card>
</template>
<script setup lang="ts">

View File

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

View File

@@ -5,6 +5,7 @@ import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth'
import { useChatsStore } from 'stores/chats'
import { useUsersStore } from 'stores/users'
import { useCompaniesStore } from 'stores/companies'
export const useSSEStore = defineStore('sse', () => {
const eventSource = ref<EventSource | null>(null)
@@ -16,6 +17,7 @@ export const useSSEStore = defineStore('sse', () => {
const authStore = useAuthStore()
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
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)
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)

View File

@@ -3,6 +3,7 @@ interface ProjectParams {
description: string | null
logo: string | null
is_logo_bg: boolean | null
chat_info: string | null
[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, " ")
}
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 {
parseIntString,
removeNullKeys,
getFieldValue,
formatNumber
formatNumber,
nameTgUser
}