before liquid glass

This commit is contained in:
2026-01-03 18:14:30 +03:00
parent 4cad91440c
commit cbaa1cda9d
64 changed files with 1927 additions and 1174 deletions

Binary file not shown.

View File

@@ -13,7 +13,6 @@
import { useI18n } from 'vue-i18n'
import type { CompanyParams } from 'types/Company'
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
const sseStore = useSSEStore()
@@ -24,7 +23,7 @@
const tg = inject('tg') as WebApp
tg.onEvent('settingsButtonClicked', async () => {
await router.push({ name: 'account' })
await router.push(authStore.isAuth ? { name: 'account' } : { name: 'settings' } )
})
watch(isAuth, async (newVal: boolean) => {

41
src/assets/logo.svg Normal file
View File

@@ -0,0 +1,41 @@
<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>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -2,86 +2,19 @@
<div
class="flex row items-center no-wrap logo-component"
>
<svg
class="iconcolor"
viewBox="0 0 8.4666662 8.4666662"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
>
<defs id="defs1" />
<g id="layer1">
<rect
class="fill-brand"
style="stroke-width:0.233149"
id="rect5"
width="6.9885192"
height="0.35581663"
x="3.114475"
y="0.86827624"
transform="matrix(0.77578367,0.63099897,-0.77578367,0.63099897,0,0)"
<img
src="src/assets/logo.svg"
style="height: 24px; max-width: 24px; padding-right: 2px;"
/>
<rect
class="fill-brand"
style="stroke-width:0.24961"
id="rect5-7"
width="7.4819207"
height="0.3809379"
x="-3.9267058"
y="5.7988153"
transform="matrix(-0.70756824,0.70664502,0.70756824,0.70664502,0,0)"
/>
<circle
class="fill-brand"
style="stroke-width:0.134869"
id="path5-8"
cx="1.5875"
cy="6.8791666"
r="1.0583333"
/>
<circle
class="fill-brand"
style="stroke-width:0.168586"
id="path5-8-5"
cx="7.1437502"
cy="7.1437502"
r="1.3229166"
/>
<circle
class="fill-brand"
style="stroke-width:0.118011"
id="path5-8-5-1"
cx="1.4552083"
cy="2.5135417"
r="0.92604166"
/>
<circle
class="fill-brand"
style="stroke-width:0.101152"
id="path5-8-5-1-7"
cx="7.1437502"
cy="1.3229166"
r="0.79374999"
/>
<circle
style="stroke-width:0.23602"
id="path5"
cx="3.96875"
cy="4.4979167"
r="1.8520833"
/>
</g>
</svg>
<span
class="text-brand"
style="margin-right: 0.075em;"
style="margin-right: 0.05em;"
>
tg
</span>
<span class="text-brand2 text-bold">
<span class="text-brand text-bold">
Crew
</span>
</div>

View File

@@ -2,24 +2,33 @@
<svg
class="q-ma-none q-pa-none"
:style="{
fill: color ?? 'red',
fill: color,
height: size ?? '42px',
width: 'auto'
}"
width="14" height="15" viewBox="0 0 14 15" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6.63869 12.1902L3.50621 14.1092C3.18049 14.3087 2.75468 14.2064 2.55515 13.8807C2.45769 13.7216 2.42864 13.5299 2.47457 13.3491L2.95948 11.4405C3.13452 10.7515 3.60599 10.1756 4.24682 9.86791L7.6642 8.22716C7.82352 8.15067 7.89067 7.95951 7.81418 7.80019C7.75223 7.67116 7.61214 7.59896 7.47111 7.62338L3.66713 8.28194C2.89387 8.41581 2.1009 8.20228 1.49941 7.69823L0.297703 6.69116C0.00493565 6.44581 -0.0335059 6.00958 0.211842 5.71682C0.33117 5.57442 0.502766 5.48602 0.687982 5.47153L4.35956 5.18419C4.61895 5.16389 4.845 4.99974 4.94458 4.75937L6.36101 1.3402C6.5072 0.987302 6.91179 0.819734 7.26469 0.965925C7.43413 1.03612 7.56876 1.17075 7.63896 1.3402L9.05539 4.75937C9.15496 4.99974 9.38101 5.16389 9.6404 5.18419L13.3322 5.47311C13.713 5.50291 13.9975 5.83578 13.9677 6.2166C13.9534 6.39979 13.8667 6.56975 13.7269 6.68896L10.9114 9.08928C10.7131 9.25826 10.6267 9.52425 10.6876 9.77748L11.5532 13.3733C11.6426 13.7447 11.414 14.1182 11.0427 14.2076C10.8642 14.2506 10.676 14.2208 10.5195 14.1249L7.36128 12.1902C7.13956 12.0544 6.8604 12.0544 6.63869 12.1902Z"></path></svg>
width="14"
height="15"
viewBox="0 0 14 15"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.63869 12.1902L3.50621 14.1092C3.18049 14.3087 2.75468 14.2064 2.55515 13.8807C2.45769 13.7216 2.42864 13.5299 2.47457 13.3491L2.95948 11.4405C3.13452 10.7515 3.60599 10.1756 4.24682 9.86791L7.6642 8.22716C7.82352 8.15067 7.89067 7.95951 7.81418 7.80019C7.75223 7.67116 7.61214 7.59896 7.47111 7.62338L3.66713 8.28194C2.89387 8.41581 2.1009 8.20228 1.49941 7.69823L0.297703 6.69116C0.00493565 6.44581 -0.0335059 6.00958 0.211842 5.71682C0.33117 5.57442 0.502766 5.48602 0.687982 5.47153L4.35956 5.18419C4.61895 5.16389 4.845 4.99974 4.94458 4.75937L6.36101 1.3402C6.5072 0.987302 6.91179 0.819734 7.26469 0.965925C7.43413 1.03612 7.56876 1.17075 7.63896 1.3402L9.05539 4.75937C9.15496 4.99974 9.38101 5.16389 9.6404 5.18419L13.3322 5.47311C13.713 5.50291 13.9975 5.83578 13.9677 6.2166C13.9534 6.39979 13.8667 6.56975 13.7269 6.68896L10.9114 9.08928C10.7131 9.25826 10.6267 9.52425 10.6876 9.77748L11.5532 13.3733C11.6426 13.7447 11.414 14.1182 11.0427 14.2076C10.8642 14.2506 10.676 14.2208 10.5195 14.1249L7.36128 12.1902C7.13956 12.0544 6.8604 12.0544 6.63869 12.1902Z">
</path>
</svg>
</template>
<script setup lang="ts">
defineProps({
color: String,
size: String,
})
withDefaults(defineProps<{
color: string
size: string // for example '24px'
}>(),
{
color: 'gold'
}
)
</script>
<style scoped>
.telegram-star-wrapper :deep(.telegram-star svg) {
fill: red;
}
</style>

View File

@@ -72,7 +72,33 @@
</q-step>
<q-step
v-if="type === 'register'"
:name="3"
:title="$t('account_helper__agreement')"
:done="step>3"
>
<agreement-block v-model="agreement"/>
<q-stepper-navigation>
<q-btn
@click="handleSubmit"
color="primary"
:label="$t('account_helper__agreement_btn')"
:disabled="agreement.length !== 2"
:unelevated="agreement.length !== 2"
class="fix-disabled-btn"
/>
<q-btn
flat
@click="step=2"
color="primary"
:label="$t('back')"
class="q-ml-sm"
/>
</q-stepper-navigation>
</q-step>
<q-step
:name="4"
:title="$t('account_helper__set_password')"
>
<q-input
@@ -111,7 +137,7 @@
/>
<q-btn
flat
@click="step=2"
@click="step= type === 'register' ? 3 : 2"
color="primary"
:label="$t('back')"
class="q-ml-sm"
@@ -123,18 +149,21 @@
<pn-magic-overlay
v-if="showSuccessOverlay"
icon="mdi-check-circle-outline"
:message1="getHelperMessage1()"
:message2="getHelperMessage2()"
route-name="projects"
:message1="getHelperMessage.m1"
:message2="getHelperMessage.m2"
:route-name="getRoute"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { AxiosError } from 'axios'
import { useI18n } from "vue-i18n"
import { QInput } from 'quasar'
import { useAuthStore, type AuthFlowType } from 'stores/auth'
import agreementBlock from 'components/agreementBlock.vue'
import { useInputRules } from 'composables/useInputRules'
const { inputRules } = useInputRules()
const flowType = computed<AuthFlowType>(() => {
return props.type === 'register'
@@ -146,7 +175,6 @@
: 'changeMethod'
})
const { t } = useI18n()
const authStore = useAuthStore()
const props = defineProps<{
@@ -154,8 +182,7 @@
email?: string
}>()
type ValidationRule = (val: string) => boolean | string
type Step = 1 | 2 | 3
type Step = 1 | 2 | 3 | 4
const step = ref<Step>(1)
const login = ref<string>(props.email || '')
@@ -164,10 +191,16 @@
const showSuccessOverlay = ref(false)
const isPwd = ref<boolean>(true)
const validationRules = {
email: [(val: string) => /.+@.+\..+/.test(val) || t('login__incorrect_email')] as [ValidationRule],
password: [(val: string) => val.length >= 8 || t('login__password_require')] as [ValidationRule]
email: [inputRules.email],
password: [inputRules.password]
}
const getRoute = computed(() => {
return (props.type === 'changeMethod' || props.type === 'changePwd')
? 'account'
: 'projects'
})
const isEmailValid = computed(() =>
validationRules.email.every(f => f(login.value) === true)
)
@@ -177,30 +210,36 @@
)
const passwordHint = computed(() => {
const result = validationRules.password[0](password.value)
const result = validationRules.password[0] ? validationRules.password[0](password.value) : null
return typeof result === 'string' ? result : ''
})
const stepActions: Record<Step, () => Promise<void>> = {
const stepActions: Record<Step, () => Promise<void> | void> = {
1: async () => {
await authStore.initRegistration(flowType.value, login.value)
},
2: async () => {
await authStore.confirmCode(flowType.value, login.value, code.value)
},
3: async () => {
3: () => {},
4: async () => {
await authStore.setPassword(flowType.value, login.value, code.value, password.value)
if (flowType.value === 'register' || flowType.value === 'changeMethod') {
await authStore.loginWithCredentials(login.value, password.value)
}
if (flowType.value === 'register') {
acceptSubmit.value = true
sessionStorage.removeItem('account-helper')
}
}
}
const handleSubmit = async () => {
try {
await stepActions[step.value]()
if (step.value < 3) {
if (step.value < 4) {
step.value++
if (step.value === 3 && props.type !== 'register') step.value++
} else {
showSuccessOverlay.value = true
}
@@ -209,26 +248,44 @@
}
}
const getHelperMessage1 = () => {
const getHelperMessage = computed(() => {
switch (flowType.value) {
case 'register': return 'account_helper__register_message1'
case 'forgot': return 'account_helper__forgot_password_message1'
case 'changePwd': return 'account_helper__change_password_message1'
case 'changeMethod': return 'account_helper__change_method_message1'
default: return ''
}
case 'register': return { m1: 'account_helper__register_message1', m2: 'slogan' }
case 'forgot': return { m1: 'account_helper__forgot_password_message1', m2: 'account_helper__go_projects' }
case 'changePwd': return { m1: 'account_helper__change_password_message1', m2: '' }
case 'changeMethod': return { m1:'account_helper__change_method_message1', m2: '' }
default: return { m1: '', m2: '' }
}
})
const getHelperMessage2 = () => {
switch (flowType.value) {
case 'register': return 'slogan'
case 'forgot':
case 'changePwd':
case 'changeMethod':
return 'account_helper__go_projects'
default: return ''
}
const agreement = ref<('terms'| 'consent')[]>([])
const acceptSubmit = ref(false)
onMounted(() => {
if (props.type !== 'register') return
const ah = sessionStorage.getItem('account-helper')
if (!ah) return
const ahVal = JSON.parse(ah)
login.value = ahVal.login
code.value = ahVal.code
agreement.value = ahVal.agreement
step.value = ahVal.step
})
onUnmounted(() => {
if (props.type === 'register' && step.value >= 3 && !acceptSubmit.value) {
sessionStorage.setItem(
'account-helper',
JSON.stringify({
login: login.value,
code: code.value,
agreement: agreement.value,
step: step.value
})
)
}
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="column w100 text-caption">
<div class="flex items-center no-wrap">
<q-checkbox v-model="modelValue" val="terms" class="q-pr-sm"/>
<span>
{{ $t('agreements__checkbox_agreement_terms') + ' ' }}
<span
@click="router.push({ name: 'terms' })"
class="cursor-pointer text-primary"
>
{{ $t('agreements__checkbox_agreement_terms_doc') }}
</span>
</span>
</div>
<div class="flex items-center no-wrap">
<q-checkbox v-model="modelValue" val="consent" class="q-pr-sm"/>
<span>
{{ $t('agreements__checkbox_agreement_consent') + ' ' }}
<span
@click="router.push({ name: 'consent' })"
class="cursor-pointer text-primary"
>
{{ $t('agreements__checkbox_agreement_consent_doc') }}
</span>
{{ ' ' + $t('agreements__checkbox_agreement_privacy') + ' ' }}
<span
@click="router.push({ name: 'privacy' })"
class="cursor-pointer text-primary"
>
{{ $t('agreements__checkbox_agreement_privacy_doc') }}
</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const modelValue = defineModel<('terms'| 'consent')[]>({ default: () => [] })
const emit = defineEmits(['agree'])
const router = useRouter()
</script>

View File

@@ -13,34 +13,39 @@
{{ $t(btnText) }}
</q-btn>
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm">
<div class="flex column items-center q-pa-md q-pb-sm w100">
<slot name="myCompany"/>
<pn-image-selector
v-model="companyMod.logo"
:size="100"
:iconsize="80"
class="q-pb-lg"
/>
<div class="q-gutter-y-lg w100">
<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"
v-model.trim="companyMod[input.val]"
:model-value="companyMod[input.val]"
@update:model-value="handleInput(input.val, $event, input.useTrim)"
dense
filled
class="w100"
class="w100 fix-bottom-padding"
:class="input.val === 'name' ? '' : 'q-pt-sm'"
:autogrow="input.val === 'description' || input.val === 'address'"
:class="input.val === 'name'
? 'fix-bottom-padding'
: input.val === 'address'
? 'input-fix q-pt-sm'
: 'q-pt-sm'"
: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"/>
@@ -50,19 +55,19 @@
<span v-if="input.val === 'name'" class="text-red">*</span>
</template>
</q-input>
</div>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CompanyParams } from 'types/Company'
import { ref, reactive, onMounted, } from 'vue'
import { removeNullKeys, getFieldValue } from 'utils/helpers'
import { useInputRules } from 'composables/useInputRules'
import type { CompanyParams } from 'types/Company'
const { t }= useI18n()
const { inputRules } = useInputRules()
const props = defineProps<{
title: string,
@@ -92,32 +97,25 @@
icon?: string
val: keyof CompanyParams
rules: ((value: string) => boolean | string)[]
}
const rules = {
name: (val: string) => !!val?.trim() || rulesErrorMessage['name']
useTrim: boolean
}
const textInputs: TextInput[] = [
{ id: 1, val: 'name', label: 'company_block__name', rules: [rules.name] },
{ id: 2, val: 'description', label: 'company_block__description', rules: [] },
{ id: 3, val: 'site', icon: 'mdi-web', rules: [] },
{ id: 4, val: 'address', icon: 'mdi-map-marker-outline', rules: [] },
{ id: 5, val: 'phone', icon: 'mdi-phone-outline', rules: [] },
{ id: 6, val: 'email', icon: 'mdi-email-outline', rules: [] }
{ id: 1, val: 'name', label: 'company_block__name', rules: [inputRules.require, inputRules.middleText], useTrim: true },
{ id: 2, val: 'description', label: 'company_block__description', rules: [inputRules.longText], useTrim: false },
{ id: 3, val: 'site', icon: 'mdi-web', rules: [inputRules.middleText], useTrim: true },
{ id: 4, val: 'address', icon: 'mdi-map-marker-outline', rules: [inputRules.longText], useTrim: false },
{ id: 5, val: 'phone', icon: 'mdi-phone-outline', rules: [inputRules.middleText], useTrim: true },
{ id: 6, val: 'email', icon: 'mdi-email-outline', rules: [inputRules.middleText], useTrim: true }
] as const
const rulesErrorMessage = {
name: t('company_block__error_name')
const handleInput = (field: keyof CompType, value: string | null | number, useTrim: boolean) => {
companyMod[field] = typeof value === 'string' ? (useTrim ? value.trim() : value) : value
if (formRef.value) formRef.value.validate()
}
const isFormValid = computed(() => {
const validations = {
name: companyMod.name && rules.name(companyMod.name) === true
}
return Object.values(validations).every(Boolean)
})
const formRef = ref()
const isFormValid = ref(false)
function onSubmit() {
// companyMod.description! - ! on end for TS, i promise: params !== null | undefined
@@ -128,11 +126,16 @@
address: getFieldValue(companyMod.address!, props.company?.address),
phone: getFieldValue(companyMod.phone!, props.company?.phone),
email: getFieldValue(companyMod.email!, props.company?.email),
logo: companyMod.logo
logo: companyMod.logo !== '' ? companyMod.logo : null
}
emit('update', removeNullKeys(outData))
emit('update', removeNullKeys(outData, ['logo']))
}
onMounted(async () => {
await formRef.value.validate()
if (!isFormValid.value) formRef.value.resetValidation()
})
</script>
<style scoped>

View File

@@ -1,5 +1,5 @@
<template>
<div id="background-canvas-wrapper" class="flex fit column">
<div id="background-canvas-wrapper" class="flex fit column bg-base">
<canvas id="canvas"/>
</div>
</template>
@@ -8,8 +8,6 @@
import { onMounted } from 'vue'
onMounted(() => {
const canvasBody = document.getElementById("canvas")
const drawArea = canvasBody.getContext("2d")
@@ -39,9 +37,7 @@ onMounted(() => {
function deBouncer () {
clearTimeout(tid)
tid = setTimeout(function() {
resizeReset()
}, delay)
tid = setTimeout(function () { resizeReset() }, delay)
}
function checkDistance (x1, y1, x2, y2) {
@@ -49,7 +45,6 @@ onMounted(() => {
}
function setup () {
resizeReset()
for (let i = 0; i < opts.particleAmount; i++) {
particles.push(new Particle())
@@ -69,7 +64,6 @@ function loop() {
}
}
function linkPoints (point1, hubs) {
for (let i = 0; i < hubs.length; i++) {
const distance = checkDistance(point1.x, point1.y, hubs[i].x, hubs[i].y)
@@ -96,18 +90,18 @@ function Particle () {
this.vector = {
x: Math.cos(this.directionAngle) * this.speed,
y: Math.sin(this.directionAngle) * this.speed
};
}
this.update = function () {
this.border();
this.border()
this.x += this.vector.x
this.y += this.vector.y
};
}
this.border = function () {
if (this.x >= w || this.x <= 0) {
this.vector.x *= -1;
this.vector.x *= -1
}
if (this.y >= h || this.y <= 0) {
this.vector.y *= -1;
this.vector.y *= -1
}
if (this.x > w) this.x = w
if (this.y > h) this.y = h
@@ -137,7 +131,6 @@ setup()
top: 0;
left: 0;
z-index: -1;
background: var(--q-primary);
margin: 0;
min-height: 100%;
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex row q-mx-sm justify-between items-center no-wrap q-py-sm"
class="flex row q-mx-sm q-my-md justify-between items-center no-wrap"
>
<q-btn
v-if="showCalendarBtn"
@@ -29,7 +29,7 @@
filled
:placeholder="$t(placeholder)"
dense
class="col-grow q-pt-xs q-mx-sm"
class="col-grow q-mx-sm"
>
<template #prepend>
<q-icon name="mdi-magnify" color="grey"/>

View File

@@ -53,40 +53,3 @@
<style>
</style>
<!-- <template>
<div
:style="{ backgroundColor: stringToColour(props.name) } "
class="fit flex items-center justify-center text-white"
>
{{ props.name ? props.name.substring(0, 1) : '' }}
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
name: string
}>()
const stringToColour = (str: string) => {
if (!str) return '#eee'
let hash = 0
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let colour = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
colour += value.toString(16).padStart(2, '0')
}
return colour
}
</script>
<style>
</style> -->

View File

@@ -11,6 +11,7 @@
style="
border-top-left-radius: var(--top-radius) !important;
border-top-right-radius: var(--top-radius) !important;
max-height: calc(100vh - 48px);
"
>
<div
@@ -36,25 +37,28 @@
:hideShadows="false"
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-px-md q-ma-none">
<div ref="cardBodyInnerRef" class="q-pa-none q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div>
<div
ref="cardFooterRef"
class="q-pa-md"
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>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { useSlots } from 'vue'
const modelValue = defineModel<boolean>({
@@ -67,8 +71,7 @@
const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null)
const cardFooterRef = ref<HTMLElement | null>(null)
const cardBodyRef = ref<HTMLElement | null>(null)
const cardFooterInnerRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
@@ -76,17 +79,20 @@
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const updateDimensions = () => {
const updateDimensions = async () => {
await nextTick(() => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0
footerHeight.value = cardFooterRef.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
})
}
watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
watch(modelValue, updateDimensions)
</script>

View File

@@ -1,44 +1,46 @@
<template>
<q-item>
<q-item-section avatar>
<q-avatar
rounded
text-color="white"
:icon="chatType.icon"
:color="chatType.color"
size="md"
/>
<q-icon :name="chat.icon" :color="chat.color" size="md"/>
</q-item-section>
<q-item-section>
<q-item-label>
<div class="row no-wrap items-center justify-between">
{{ $t(chatType.label) }}
<span class="text-caption text-grey" v-if="time">
{{ date.formatDate(time * 1000, 'DD MMM YYYY') }}
{{ $t(chat.label) }}
<span class="text-caption text-grey text-no-wrap" v-if="time">
{{ getDate(time) }}
</span>
</div>
</q-item-label>
<q-item-label class="text-grey text-caption">
{{ $t(chatType.description) }}
{{ $t(chat.description) }}
</q-item-label>
</q-item-section>
</q-item>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getChatType } from 'utils/chats'
import { chatAccess, chatState } from 'utils/chats'
import { date } from 'quasar'
import type { ChatType } from 'types/Chat'
const props = withDefaults(defineProps<{
type: ChatType
const props = defineProps<{
access?: 0 | 1 | 2
state?: 1 | 2 | 3 | 4
time?: number
}>(), {
type: 'full'
})
}>()
const chat = props.access
? chatAccess(props.access)
: props.state
? chatState(props.state)
: { info: '', icon: '', color: '', label: '', description: '' }
function getDate (d: number) {
return new Date(d * 1000).getFullYear() === new Date(Date.now()).getFullYear()
? date.formatDate(d * 1000, 'DD MMM')
: date.formatDate(d * 1000, 'DD MMM YY')
}
const chatType = computed(() => getChatType(props.type))
</script>
<style scoped>

View File

@@ -14,7 +14,7 @@
:style="{ display: 'none' }"
@update:model-value="handleUpload()"
:filter="checkImgType"
accept="image/*"
:accept="allowedMimeTypes.join(', ')"
/>
<q-icon
v-if="modelValue === '' || modelValue === undefined || modelValue === null"
@@ -37,21 +37,34 @@
</div>
<q-dialog v-model="showDialog">
<q-card class="w100 relative-position" style="height: auto;">
<q-card
class="w100 relative-position overflow-hidden"
style="height: auto;"
>
<q-img :src="modelValue"/>
<q-btn
icon="mdi-close"
@click="showDialog = false"
class="absolute-top-right q-ma-sm"
color="grey-9"
round flat
style="background-color: rgba(255, 255, 255, 0.65) !important;"
/>
<div
class="flex row items-center jutsify-center q-pb-sm"
style="bottom: 0; position: absolute; left: 50%; transform: translate(-50%, 0%);">
class="flex no-wrap items-center justify-center q-pb-sm absolute-bottom w100"
>
<q-btn
v-for="btn in menuBtns"
:key="btn.name"
:icon="btn.icon"
:color="btn.color"
@click="btn.f"
class="q-mx-xs bg-white"
round flat
style="opacity: 0.8"
color="primary"
/>
class="q-mx-sm"
rounded flat
style="background-color: rgba(255, 255, 255, 0.65) !important;"
>
{{ $t(btn.label) }}
</q-btn>
</div>
</q-card>
</q-dialog>
@@ -72,10 +85,10 @@
}>()
const compressionOptions = {
maxSizeMB: 0.5, // Максимальный размер ~500 КБ
maxWidthOrHeight: 1200, // Максимальное разрешение
useWebWorker: true, // Для производительности
fileType: 'image/jpeg' // Формат вывода
maxSizeMB: 0.5, // Max size ~500 KB
maxWidthOrHeight: 1200, // Max resolution
useWebWorker: true, // Performance
fileType: 'image/jpeg' // Output format
}
const imageFile = ref(null) // file-from selector
@@ -83,10 +96,10 @@
const size = ref<number>(props.size ? props.size : 100)
const iconsize = ref<number>(props.iconsize ? props.iconsize : 75)
const showDialog = ref<boolean>(false)
const menuBtns = [
{ name: 'change', icon: 'mdi-swap-horizontal', f: imgFileSelectorClick },
{ name: 'delete', icon: 'mdi-delete-outline', f: deleteImage },
{ name: 'close', icon: 'mdi-close', f: () => showDialog.value = false }
{ name: 'delete', color: 'negative', label: 'image_selector__delete', f: deleteImage },
{ name: 'change', color: 'primary', label: 'image_selector__change', f: imgFileSelectorClick }
]
const sizePx = computed(() => {
@@ -99,14 +112,14 @@
const compressedFile = await imageCompression(
imageFile.value,
compressionOptions
);
)
const img = await imgToBase64(compressedFile);
modelValue.value = typeof img === 'string' ? img : '';
modelValue.value = typeof img === 'string' ? img : ''
} catch (error) {
console.error('Image error compression:', error);
const img = await imgToBase64(imageFile.value);
modelValue.value = typeof img === 'string' ? img : '';
console.error('Image error compression:', error)
const img = await imgToBase64(imageFile.value)
modelValue.value = typeof img === 'string' ? img : ''
}
}
}
@@ -131,21 +144,20 @@
reject(new Error('Something went wrong'))
}
reader.onload = () => {
resolve(reader.result)
}
reader.onload = () => resolve(reader.result)
})
}
function checkImgType(files: File[]): File[] {
const allowedTypes = [
const allowedMimeTypes = [
'image/jpeg',
'image/png',
'image/bmp',
'image/webp',
'image/gif',
'image/bmp'
'image/gif'
]
return files.filter((file: File) => allowedTypes.includes(file.type))
function checkImgType (files: File[]): File[] {
return files.filter((file: File) => allowedMimeTypes.includes(file.type))
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div class="row no-wrap items-center w100">
<div
v-if="item1"
class="second-line-item flex no-wrap items-center q-mr-sm"
>
<q-icon
v-if="item1Icon"
:name="item1Icon"
:color="item1IconColor"
class="q-mr-none"
/>
<div
class="ellipsis"
:class="item1Class ?? '' "
>
{{ item1 }}
</div>
</div>
<div
v-if="item2"
class="second-line-item flex no-wrap items-center"
>
<q-icon
v-if="item2Icon"
:name="item2Icon"
:color="item2IconColor"
class="q-mr-none"
/>
<div
class="ellipsis"
:class="item2Class ?? '' "
>
{{ item2 }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
item1: string | null
item1Class?: string
item1Icon?: string
item1IconColor?: string
item2: string | null
item2Class?: string
item2Icon?: string
item2IconColor?: string
}>()
</script>
<style scoped>
.second-line-item {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
}
</style>

View File

@@ -2,14 +2,13 @@
<div class="flex w100 column q-pt-xl q-pa-md">
<div class="flex column justify-center col-grow items-center text-grey">
<q-btn
flat
no-caps
flat no-caps
@click="handleClick"
v-if="!noBtn"
>
<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">
<div class="text-h6 text-brand text-center">
{{ message1 }}
</div>
</div>
@@ -17,7 +16,7 @@
<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">
<div class="text-h6 text-brand text-center">
{{ message1 }}
</div>
</div>

View File

@@ -11,17 +11,28 @@
<q-resize-observer @resize="onHeaderResize"/>
<slot name="card-body-header"/>
</div>
<div id="card-body"
class="top-rounded-card bg-white"
<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 style="height:100%">
<div class="column col col-grow">
<q-resize-observer @resize="onBodyResize"/>
<pn-shadow-scroll :hideShadows="isResizing" :height="scrollAreaHeight">
<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>
@@ -31,6 +42,9 @@
<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'])
@@ -38,8 +52,10 @@
// need if slot draw by expression with v-if
withDefaults(defineProps<{
hideHeader?: boolean
scrollPosition?: number
}>(), {
hideHeader: false
hideHeader: false,
scrollPosition: 0
})
const heightCard = ref(100)
@@ -69,6 +85,7 @@
async function updateScrollAreaHeight() {
await nextTick(() => {
scrollAreaHeight.value = Math.max(0, heightCard.value)
emit('updateHeight', scrollAreaHeight.value)
})
}

View File

@@ -1,8 +1,9 @@
<template>
<div :class="{'fix-scroll-area-content': hideShadows }">
<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"
class="w100 q-pa-none q-ma-none column col col-grow"
@scroll="onScroll"
:class=" {
'shadow-top': hasScrolled,
@@ -10,19 +11,21 @@
}"
>
<slot/>
<div class="q-pa-sm"/>
</q-scroll-area>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onActivated, watch } from 'vue'
defineProps<{
const props = defineProps<{
height: number
hideShadows: boolean
scrollPosition?: number
}>()
const emit = defineEmits(['updateScrollPosition'])
const hasScrolled = ref(false)
const hasScrolledBottom = ref(false)
@@ -37,8 +40,19 @@
hasScrolled.value = info.verticalPosition > 0
const scrollEnd = info.verticalPosition + info.verticalContainerSize >= info.verticalSize - 1
hasScrolledBottom.value = !scrollEnd
emit('updateScrollPosition', scrollAreaRef.value?.getScrollPosition().top || 0)
}
const scrollAreaRef = ref()
onActivated(() => {
scrollAreaRef.value?.setScrollPosition('vertical', props?.scrollPosition || 0)
})
watch(() => props.scrollPosition, (newPosition) => {
scrollAreaRef.value?.setScrollPosition('vertical', newPosition)
}, { immediate: true })
</script>
<style scoped>
@@ -87,6 +101,10 @@
z-index: 1;
}
.fix-scroll-area-content-width:deep(.q-scrollarea__content) {
width: 100%;
}
.fix-scroll-area-content:deep(.q-scrollarea::before) {
content: none;
}

View File

@@ -1,7 +1,8 @@
<template>
<q-dialog v-model="modelValue">
<q-dialog v-model="modelValue" class="q-ma-none">
<q-card
class="q-pa-none q-ma-none w100 no-scroll"
class="q-pa-none q-ma-none w100 no-scroll rounded-card"
style="max-height: calc(100vh - 48px);"
>
<q-card-section class="row items-center q-pb-none">
<div class="flex no-wrap items-center w100">

View File

@@ -1,7 +1,7 @@
<template>
<q-dialog v-model="modelValue">
<q-card
class="q-pa-none q-ma-none w100 no-scroll"
class="q-pa-none q-ma-none w100 no-scroll rounded-card"
align="center"
>
<q-card-section>

View File

@@ -0,0 +1,57 @@
<template>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<slot/>
<q-item-label lines="1" v-if="showStatus && getUserStatus(item) && getUserStatus(item)?.status">
<q-badge :color="getUserStatus(item)?.color">
{{ $t(getUserStatus(item)?.text ?? '') }}
</q-badge>
</q-item-label>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="2" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption v-if="item.section2_1 || item.section2_2">
<pn-inline-pair
:item1="item.section2_1 ? item.section2_1 :null"
item1Class="text-bold"
item1Icon="telegram"
item1IconColor="tgcolor"
:item2="item.section2_2 ? '@' + item.section2_2 : null"
item2Class="text-blue"
/>
</q-item-label>
</q-item-section>
</template>
<script setup lang="ts">
import { getUserStatus } from 'utils/users'
import pnInlinePair from 'components/pnInlinePair.vue'
import type { User } from 'types/User'
type UserListItem = User & {
section1: string
section2_1: string
section2_2: string
section3: string
is_blocked: boolean
is_leave: boolean
companyName?: string | null
}
defineProps<{
item: UserListItem
showStatus?: boolean
}>()
</script>
<style scoped>
</style>

View File

@@ -15,7 +15,7 @@
</template>
<pn-scroll-list>
<div class="flex column items-center q-pa-md q-pb-sm">
<div class="flex column items-center q-pa-md q-pb-sm w100">
<pn-image-selector
v-model="projectMod.logo"
:size="100"
@@ -23,15 +23,23 @@
type="rounded"
class="q-pb-lg"
/>
<div class="q-gutter-y-lg w100">
<q-form
ref="formRef"
@validation-success="isFormValid = true"
@validation-error="isFormValid = false"
class="q-gutter-y-lg w100"
>
<q-input
v-model="projectMod.name"
v-model.trim="projectMod.name"
no-error-icon
dense
filled
label-slot
class = "w100 fix-bottom-padding"
:rules="[rules.name]"
:rules="[inputRules.require, inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
>
<template #label>
{{ $t('project_block__project_name') }} <span class="text-red">*</span>
@@ -43,10 +51,14 @@
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"
/>
</div>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
@@ -54,12 +66,12 @@
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { ref, reactive, onMounted } from 'vue'
import { useInputRules } from 'composables/useInputRules'
import type { ProjectParams } from 'types/Project'
import { removeNullKeys, getFieldValue } from 'utils/helpers'
const { t } = useI18n()
const { inputRules } = useInputRules()
const props = defineProps<{
title: string,
@@ -75,31 +87,22 @@
logo: props.project?.logo ?? null
})
const rulesErrorMessage = {
name: t('project_block__error_name')
}
const rules = {
name: (val: string) => !!val?.trim() || rulesErrorMessage['name']
}
const isFormValid = computed(() => {
const validations = {
name: projectMod.name && rules.name(projectMod.name) === true
}
return Object.values(validations).every(Boolean)
})
const formRef = ref()
const isFormValid = ref(false)
function onSubmit() {
const outData = {
name: projectMod.name,
description: getFieldValue(projectMod.description, props.project?.description),
logo: projectMod.logo
logo: projectMod.logo !== '' ? projectMod.logo : null
}
emit('update', removeNullKeys(outData))
emit('update', removeNullKeys(outData, ['logo']))
}
onMounted(async () => {
await formRef.value.validate()
if (!isFormValid.value) formRef.value.resetValidation()
})
</script>
<style scoped>

View File

@@ -0,0 +1,81 @@
<template>
<div class="w100">
<q-btn-group spread flat class="w100 q-py-xs">
<q-btn
v-for="period in periods"
:key="period.id"
:outline="selectedPeriod === period.id"
@click="selectedPeriod = period.id"
:color="selectedPeriod === period.id ? 'primary' : ''"
no-caps dense
>
<div class="column items-center w100 self-start">
<span
class="text-caption text-grey text-no-wrap"
>
{{ $t(period.name_in_days) }}
</span>
<div
class="text-h6"
:class="selectedPeriod === period.id ? 'text-primary' : 'text-black'"
style="line-height: 1em !important;"
>
{{ $t(period.name) }}
</div>
<div
class="row no-wrap w100 q-px-sm q-pt-xs justify-center"
>
<div
v-if="getPeriodPrice(period)"
class="flex no-wrap text-caption text-grey items-center q-pa-none q-ma-none q-pr-xs"
>
<telegram-star color="gold" size="12px" class="q-mr-xs"/>
{{ formatNumber(getPeriodPrice(period)) }}
</div>
<q-badge color="red" v-if="period.discount">
{{ period.discount * 100 }}%
</q-badge>
</div>
</div>
</q-btn>
</q-btn-group>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import telegramStar from 'components/TelegramStar.vue'
import { formatNumber } from 'utils/helpers'
import type { TariffPeriod } from 'types/Tariff'
const props = defineProps<{
periods: TariffPeriod[],
tariffPrice: number
}>()
const emit = defineEmits(['update:selected'])
const selectedPeriod = ref<number>()
watch(() => props.periods, () => {
if (props.periods.length !== 0) {
selectedPeriod.value = props.periods[0]?.id
}
}, { immediate: true })
watch(selectedPeriod, (newVal) => {
if (newVal !== undefined) emit('update:selected', newVal)
}, { immediate: true })
function getPeriodPrice (period: TariffPeriod) {
return Math.floor(props.tariffPrice * (1 - period.discount) * period.id / 30)
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="w100">
<div class="w100 column items-center text-center text-grey q-px-md">
<div class="text-caption">
{{ $t('subscribe_tariffs__description') }}
</div>
</div>
<q-list separator>
<q-item
v-for="item in tariffs"
:key="item.id"
clickable
v-ripple
tag="label"
:disable="disableTestTariff(item.id)"
>
<q-item-section avatar>
<q-radio
v-model="selectedTariff"
:val="item.id"
:disable="disableTestTariff(item.id)"
:keep-color="disableTestTariff(item.id)"
:color="disableTestTariff(item.id) ? 'grey-5' : ''"
/>
</q-item-section>
<q-item-section>
<div class="column">
<span class="text-bold">
{{ item.name }}
</span>
<div class="flex no-wrap items-center text-caption" style="line-height: 1em;">
<span v-if="item.chat_limit">
{{ $t('subscribe_tariffs__chats_max') + ' ' + item.chat_limit }}
</span>
<q-icon v-else name="mdi-all-inclusive" size="sm"/>
<span class="q-pl-xs">
{{ $t('subscribe_tariffs__chats')}}
</span>
</div>
</div>
</q-item-section>
<q-item-section
class="text-left"
:style="{ width: `${maxWidthStarsSection}px` }"
>
<q-resize-observer @resize="updateMaxWidthStarsSection" />
<div v-if="item.price" class="flex column justify-start">
<div class="flex no-wrap items-center">
<telegram-star color="gold" size="18px" class="q-mr-xs"/>
<span class="text-h6">{{ formatNumber(item.price) }}</span>
<span class="text-caption q-pl-xs">{{ $t('subscribe_tariffs__per_month') }}</span>
</div>
</div>
<div v-else class="text-bold self-center">
{{ $t('subscribe_tariffs__free_tax') }}
</div>
</q-item-section>
</q-item>
</q-list>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import telegramStar from 'components/TelegramStar.vue'
import { formatNumber } from 'utils/helpers'
import type { Tariff } from 'types/Tariff'
const props = withDefaults(defineProps<{
tariffs: Tariff[]
currentTariffId?: number
disable?: boolean
}>(),
{
disable: false
}
)
const emit = defineEmits(['update:selected'])
const selectedTariff = ref<number>()
const testTariffId = computed(() => props.tariffs.find(el => el.price === 0)?.id)
watch(() => props.tariffs, () => {
if (props.currentTariffId !== undefined) {
selectedTariff.value = props.currentTariffId
} else if (testTariffId.value) {
selectedTariff.value = testTariffId.value
} else if (props.tariffs.length > 0) {
selectedTariff.value = props.tariffs[0]?.id
}
}, { immediate: true })
watch(() => props.currentTariffId, (newId) => {
if (newId !== undefined) selectedTariff.value = newId
})
watch(selectedTariff, (newVal) => {
if (newVal !== undefined) emit('update:selected', newVal)
}, { immediate: true })
function disableTestTariff (tariffId: number) {
if (props.disable) return true
if (!testTariffId.value) return false
return props.currentTariffId === testTariffId.value || tariffId === testTariffId.value
}
const maxWidthStarsSection = ref(0)
function updateMaxWidthStarsSection(size: { width: number, height: number }) {
if (size.width > maxWidthStarsSection.value) {
maxWidthStarsSection.value = size.width
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,253 @@
<template>
<div class="column w100 q-px-md">
<div class="flex justify-end w100">
<q-btn
flat no-caps color="grey" rounded dense
@click="showTarfiffSelectDialog = true"
>
<span class="q-px-xs text-caption">
{{$t('subscribe__dialog_select_tariff_show')}}
</span>
</q-btn>
</div>
<div class="w100 text-grey text-center q-pt-sm">
{{ t('subscribe__tariffs_interval_renewal') }}
</div>
<pn-subscribe-periods
@update:selected="selectedPeriod = $event"
:periods
:tariff-price="selectedTariffPrice"
/>
<div
class="text-caption column rounded-card q-px-md q-py-sm q-mt-md"
style="border: 1px solid var(--q-negative);"
v-if="currentTariff?.debt !== 0"
>
<div class="row justify-between item-center text-negative">
<span>
{{ $t('suscribe__credit_from') + ' ' +
(currentTariff?.end_date
? date.formatDate(currentTariff?.end_date * 1000, 'DD.MM.YYYY')
: '')
+ ': ' }}
</span>
<div class="flex no-wrap items-center q-pa-none q-ma-none">
<telegram-star color="gold" size="12px" class="q-mr-xs"/>
{{ formatNumber(currentTariff?.debt ?? '') }}
</div>
</div>
<span class="text-grey">
{{ $t('suscribe__credit_description') }}
</span>
</div>
<div class="text-center text-grey w100 text-caption q-pt-lg">
{{ $t('subscribe__promocode_description') }}
</div>
<q-input
v-model="promocode"
dense
filled
:label="$t('subscribe__promocode')"
class="q-pt-sm"
>
<template #append v-if="promocode !== ''">
<q-btn
round dense flat
color="primary"
icon="mdi-arrow-right-circle-outline"
@click="clickSendPromocode"
/>
</template>
</q-input>
</div>
<pn-bottom-sheet-dialog
v-if="tariffs && currentTariff"
title="subscribe__dialog_select_tariff_title"
v-model="showTarfiffSelectDialog"
>
<div class="w100 column">
<q-banner
v-if="!currentTariff.can_change"
class="bg-negative text-white q-mb-md"
>
<div>
{{ $t(messageBlockChange?.body ?? '') }}
</div>
<div class="text-caption" style="opacity: 0.8">
{{ $t(messageBlockChange?.description ?? '') }}
</div>
</q-banner>
<pn-subscribe-tariffs
@update:selected="selectedTariff = $event"
:tariffs
:currentTariffId="currentTariff.id"
:disable="!currentTariff.can_change"
/>
<div class="text-caption text-grey q-px-md q-py-md">
<div class="w100">
{{ $t('subscribe__dialog_select_tariff_notes_title') }}
</div>
<div class="w100">
{{ $t('subscribe__dialog_select_tariff_notes_1') }}
</div>
<div class="w100">
{{ $t('subscribe__dialog_select_tariff_notes_2') }}
</div>
<div class="w100">
{{ $t('subscribe__dialog_select_tariff_notes_3') }}
</div>
<div
class="w100 text-info cursor-pointer"
@click="router.push({ name: 'change_tariff_rules'})"
>
{{ $t('subscribe__dialog_select_tariff_notes_more') }}
</div>
</div>
</div>
<template #footer>
<div class="column">
<q-btn
rounded
class="w100"
color="primary"
v-close-popup
:outline="!currentTariff.can_change || currentTariff?.id === selectedTariff"
@click="clickChangeTariff"
>
{{ $t(currentTariff.can_change && currentTariff?.id !== selectedTariff
? 'subscribe__dialog_select_tariff_btn'
: 'subscribe__dialog_select_tariff_btn_cancel'
) }}
</q-btn>
<div
v-if="props.currentTariff?.id !== selectedTariff"
class="q-px-md text-caption text-negative text-center"
>
{{ $t('subscribe__dialog_select_tariff_recalc_msg') + ': ' }}
{{
typeof activePeriodMessage === 'number'
? date.formatDate(activePeriodMessage * 1000, 'DD.MM.YYYY')
: 'Error'
}}
</div>
</div>
</template>
</pn-bottom-sheet-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { date, useQuasar } from 'quasar'
import { formatNumber } from 'utils/helpers'
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,
calcRenewTariff,
urlRenewTariff,
sendPromocode
} from 'composables/useSubscription'
import type { Tariff, CurrentTariff, TariffPeriod } from 'types/Tariff'
const { t } = useI18n()
const router = useRouter()
const props = defineProps<{
tariffs: Tariff[]
currentTariff?: CurrentTariff
periods: TariffPeriod[]
}>()
const emit = defineEmits(['update'])
const selectedTariff = ref<number>()
const selectedPeriod = ref<number>()
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 &&
props.currentTariff.can_change &&
props.currentTariff?.id !== selectedTariff.value)
await changeTariff(selectedTariff.value)
}
const messageBlockChange = computed(() => {
if (props.currentTariff && !props.currentTariff.can_change) {
return props.currentTariff.debt === 0
? {
body: 'subscribe__dialog_select_tariff_warn_debt_msg_1',
description: 'subscribe__dialog_select_tariff_warn_debt_msg_2'
}
: {
body: 'subscribe__dialog_select_tariff_warn_once_day_msg_1',
description: 'subscribe__dialog_select_tariff_warn_once_day_msg_2'
}
}
})
const activePeriodMessage = ref('')
watch(selectedTariff, async (newVal) => {
activePeriodMessage.value = (newVal && newVal !== props.currentTariff?.id )
? (await calcChangeTariff(newVal)).plan_end_date
: ''
})
watch(showTarfiffSelectDialog, async (newVal) => {
if (newVal) emit('update', { status: 'hide' })
else await updatePayment()
})
watch(selectedPeriod, async () => await updatePayment())
const updatePayment = async () => {
if (!props.currentTariff?.id || !selectedPeriod.value) return
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)
emit('update', { status: 'show', textBtn, url })
}
// promocode
const promocode = ref('')
const $q = useQuasar()
async function clickSendPromocode () {
const result = await sendPromocode(promocode.value)
if (result) {
showNotify('success')
} else
showNotify('fail')
promocode.value = ''
}
const showNotify = (message: 'success' | 'fail') => {
$q.notify({
message: t(message === 'success' ? 'subscribe__promocode_success' : 'subscribe__promocode_fail'),
type: message === 'success' ? 'positive' : 'negative',
position: 'bottom',
timeout: 1000,
multiLine: true
})
}
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,76 @@
<template>
<!-- only for test tariff -->
<div
class="column w100"
v-if="testTariffId && tariffs.length > 0 && periods.length > 0"
>
<div class="w100 text-grey text-center">
{{ $t('subscribe__tariffs') }}
</div>
<pn-subscribe-tariffs
@update:selected="selectedTariff = $event"
:tariffs
/>
<q-slide-transition>
<div v-if="selectedTariff !== testTariffId">
<div class="w100 text-grey text-center q-pt-md">
{{ $t('subscribe__tariffs_interval') }}
</div>
<pn-subscribe-periods
@update:selected="selectedPeriod = $event"
:periods
:tariff-price="selectedTariffPrice"
class="q-px-md"
/>
</div>
</q-slide-transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { date } from 'quasar'
import { formatNumber } from 'utils/helpers'
import pnSubscribeTariffs from 'components/subscribe/pnSubscribeTariffs.vue'
import pnSubscribePeriods from 'components/subscribe/pnSubscribePeriods.vue'
import {
calcUpgradeTariff,
urlUpgradeTariff
} from 'composables/useSubscription'
import type { Tariff, TariffPeriod } from 'types/Tariff'
const { t } = useI18n()
const props = defineProps<{
tariffs: Tariff[]
periods: TariffPeriod[]
}>()
const emit = defineEmits(['tariff-upgrade'])
const selectedTariff = ref<number>()
const selectedPeriod = ref<number>()
const testTariffId = computed(() => props.tariffs.find(el => el.price === 0)?.id ?? null)
const selectedTariffPrice = computed(() => props.tariffs.find((el: Tariff) => el.id === selectedTariff.value)?.price ?? 0)
watch([selectedTariff, selectedPeriod], async () => {
if (selectedTariff.value === testTariffId.value) {
emit('tariff-upgrade', { status: 'hide' })
return
}
if (!selectedTariff.value || !selectedPeriod.value) return
const upgrade = await calcUpgradeTariff(selectedTariff.value, selectedPeriod.value)
const textBtn = t('subscribe__pay') + ' ⭐' + formatNumber(upgrade.amount) +
' (' + t('subscribe__tariff_exp') + ' ' + date.formatDate(upgrade.end_date * 1000, 'DD.MM.YYYY') + ')'
const url = await urlUpgradeTariff(selectedTariff.value, selectedPeriod.value)
emit('tariff-upgrade', { status: 'show', textBtn, url })
})
</script>
<style lang="scss">
</style>

View File

@@ -7,6 +7,7 @@
<q-btn
rounded color="primary"
class="w100 fix-disabled-btn"
:disable="!isFormValid"
@click = "onSubmit"
>
{{ $t(btnText) }}
@@ -38,17 +39,25 @@
<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-blue text-caption" align="center" v-if="user?.username">{{ user.username }}</div>
<div caption class="text-primary text-caption" align="center" v-if="user?.username">{{ user.username }}</div>
</div>
</div>
<div class="q-gutter-y-lg w100">
<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"
class = "w100 fix-bottom-padding"
:label = "$t('user_block__name')"
:rules="[inputRules.middleText]"
@update:model-value="formRef.validate()"
debounce="300"
/>
<q-select
@@ -61,6 +70,7 @@
option-value="id"
emit-value
map-options
@update:model-value="formRef.validate()"
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
@@ -91,23 +101,32 @@
v-model.trim="userMod.department"
dense
filled
class = "w100 q-pt-sm"
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 q-pt-sm"
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 q-pt-sm"
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"/>
@@ -118,14 +137,16 @@
v-model.trim="userMod.email"
dense
filled
class = "w100 q-pt-sm"
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>
</div>
</q-form>
</div>
</pn-scroll-list>
</pn-page-card>
@@ -133,14 +154,16 @@
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { ref, computed, reactive, onMounted } from 'vue'
import { useCompaniesStore } from 'stores/companies'
import { useI18n } from 'vue-i18n'
import { useInputRules } from 'composables/useInputRules'
import { removeNullKeys, getFieldValue } from 'utils/helpers'
import { getUserStatus } from 'utils/users'
import type { User } from 'types/User'
const { t } = useI18n()
const { inputRules } = useInputRules()
const props = defineProps<{
title: string,
@@ -184,6 +207,9 @@
const userStatus = computed(() => props.user ? getUserStatus(props.user) : null)
const formRef = ref()
const isFormValid = ref(false)
function onSubmit() {
const outData = {
fullname: getFieldValue(userMod.fullname, props.user?.fullname),
@@ -192,11 +218,15 @@
role: getFieldValue(userMod.role, props.user?.role),
phone: getFieldValue(userMod.phone, props.user?.phone),
email: getFieldValue(userMod.email, props.user?.email),
photo: userMod.photo
}
emit('update', removeNullKeys(outData))
emit('update', removeNullKeys(outData, ['company_id']))
}
onMounted(async () => {
await formRef.value.validate()
if (!isFormValid.value) formRef.value.resetValidation()
})
</script>
<style scoped>

View File

@@ -0,0 +1,18 @@
import { useI18n } from 'vue-i18n'
export const useInputRules = () => {
const { t } = useI18n()
return {
inputRules: {
require: (val: string) => !!val?.trim() || t('input_rules__require'),
email: (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(val) || t('input_rules__incorrect_email'),
password: (val: string) => val.length >= 8 || t('input_rules__password_require'),
middleText: (val: string) => val.length <= 60 || t('input_rules__middle_text'),
longText: (val: string) => val.length <= 250 || t('input_rules__long_text')
}
}
}

View File

@@ -0,0 +1,49 @@
import { api } from 'boot/axios'
async function getTariffs () {
return (await api.get('/payment/plan'))?.data.data
}
async function getTariffDiscount () {
return (await api.get('payment/plan/discount'))?.data.data
}
async function calcUpgradeTariff (plan_id: number, period: number) {
return (await api.get('/payment/plan/upgrade/calc', { params: { plan_id, period } }))?.data.data
}
async function urlUpgradeTariff (plan_id: number, period: number) {
return (await api.post('/payment/plan/upgrade', { plan_id, period }))?.data.data.url
}
async function calcChangeTariff (plan_id: number) {
return (await api.get('/payment/plan/change/calc', { params: { plan_id } }))?.data.data
}
async function changeTariff (plan_id: number) {
return (await api.post('/payment/plan/change', { plan_id }))?.data.data
}
async function calcRenewTariff (plan_id: number, period: number) {
return (await api.get('/payment/plan/renew/calc', { params: { plan_id, period } }))?.data.data
}
async function urlRenewTariff (plan_id: number, period: number) {
return (await api.post('/payment/plan/renew', { plan_id, period }))?.data.data.url
}
async function sendPromocode (promocode: string) {
return (await api.post('/payment/plan/promocode', { promocode }))?.data.success
}
export {
getTariffs,
getTariffDiscount,
calcUpgradeTariff,
urlUpgradeTariff,
calcChangeTariff,
changeTariff,
calcRenewTariff,
urlRenewTariff,
sendPromocode
}

View File

@@ -19,7 +19,7 @@ export function useUserSection() {
const section3 = (
user.company_id && companiesStore.companyById(user.company_id)
? companiesStore.companyById(user.company_id)?.name + ((user.role || user.department) ? ' - ' : '')
? companiesStore.companyById(user.company_id)?.name + ((user.role || user.department) ? ', ' : '')
: ''
) +
(user.department ? user.department + (user.role ? ' - ' : '') : '') +

View File

@@ -15,6 +15,18 @@
background: $brand2 !important;
}
.bg-base {
background: $base-bg !important;
}
.text-tgcolor {
color: $tgcolor !important;
}
.bg-tgcolor {
background: $tgcolor !important;
}
$base-width: 100;
@while $base-width > 0 {
.w#{$base-width} { width: #{$base-width}+'%'; }

View File

@@ -1,4 +1,4 @@
$primary : #3390ec;
$primary : #419FD9;
$secondary : #26A69A;
$accent : #9C27B0;
@@ -12,8 +12,11 @@ $warning : #F2C037;
$lightgrey : #DCDCDC;
$brand: #3390ec;
$brand: #419FD9;
$brand2: #F36D3A;
$base-bg: #517DA2;
$tgcolor: #419FD9;
$body-font-size: var(--dynamic-font-size);

View File

@@ -1,18 +0,0 @@
// This is just an example,
// so you can safely delete all default props below
export default {
login__forgot_password: 'Forgot password?',
login__login: 'Login',
login__password: 'Password',
login__sign_in: 'Sign in',
login__sign_up: 'Register',
login__welcome: 'Welcome!',
project__companies: 'Companies',
project__persons: 'Persons',
project__chats: 'Chats',
projects__projects: 'Projects',
projects__account: 'Account',
failed: 'Action failed',
success: 'Action was successful'
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,9 +11,6 @@
<script setup lang="ts">
import accountHelper from 'components/accountHelper.vue'
import { useAuthStore } from 'stores/auth'
const authStore= useAuthStore()
const type = 'changeMethod'

View File

@@ -192,14 +192,13 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { AxiosError } from 'axios'
import { useI18n } from "vue-i18n"
import { QInput } from 'quasar'
import { useAuthStore } from 'stores/auth'
import { useInputRules } from 'composables/useInputRules'
const { t } = useI18n()
const { inputRules } = useInputRules()
const authStore = useAuthStore()
type ValidationRule = (val: string) => boolean | string
type Step = 1 | 2 | 3 | 4 | 5
const step = ref<Step>(1)
@@ -211,8 +210,8 @@
const showSuccessOverlay = ref(false)
const isPwd = ref<boolean>(true)
const validationRules = {
email: [(val: string) => /.+@.+\..+/.test(val) || t('login__incorrect_email')] as [ValidationRule],
password: [(val: string) => val.length >= 8 || t('login__password_require')] as [ValidationRule]
email: [inputRules.email],
password: [inputRules.password]
}
const isEmailValid = computed(() =>
@@ -224,7 +223,7 @@
)
const passwordHint = computed(() => {
const result = validationRules.password[0](password.value)
const result = validationRules.password[0] ? validationRules.password[0](password.value) : null
return typeof result === 'string' ? result : ''
})

View File

@@ -14,9 +14,10 @@
import accountHelper from 'components/accountHelper.vue'
const type = 'register'
const email = ref(sessionStorage.getItem('pendingLogin') || '')
const email = ref('')
onMounted(() => {
email.value = sessionStorage.getItem('pendingLogin') ?? ''
sessionStorage.removeItem('pendingLogin')
})

View File

@@ -9,37 +9,9 @@
<div v-if="route.query.type === 'update'">{{ $t('agreements__description_update') }}</div>
<div>{{ $t('agreements__description') }}</div>
</div>
<div class="flex items-center no-wrap">
<q-checkbox v-model="agreement" val="1" class="q-pr-sm"/>
<span>
{{ $t('agreements__checkbox_agreement_terms') + ' ' }}
<span
@click="router.push({ name: 'terms' })"
class="cursor-pointer text-primary"
>
{{ $t('agreements__checkbox_agreement_terms_doc') }}
</span>
</span>
</div>
<div class="flex items-center no-wrap">
<q-checkbox v-model="agreement" val="2" class="q-pr-sm"/>
<span>
{{ $t('agreements__checkbox_agreement_consent') + ' ' }}
<span
@click="router.push({ name: 'consent' })"
class="cursor-pointer text-primary"
>
{{ $t('agreements__checkbox_agreement_consent_doc') }}
</span>
{{ ' ' + $t('agreements__checkbox_agreement_privacy') + ' ' }}
<span
@click="router.push({ name: 'privacy' })"
class="cursor-pointer text-primary"
>
{{ $t('agreements__checkbox_agreement_privacy_doc') }}
</span>
</span>
</div>
<agreement-block
v-model="agreement"
/>
</div>
</pn-scroll-list>
<template #footer>
@@ -74,9 +46,10 @@
</template>
<script setup lang="ts">
import { ref, inject } from 'vue'
import { ref, inject, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from 'stores/auth'
import agreementBlock from 'components/agreementBlock.vue'
import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp
@@ -88,32 +61,40 @@
const authStore = useAuthStore()
const router = useRouter()
const submitAccept = ref(false)
async function onSubmit () {
if (!route.query.method || !route.query.type) {
sessionStorage.removeItem('agreement')
submitAccept.value = true
if (!route.query.type) {
await authStore.logout()
await router.push({ name: 'login' })
return
}
if (route.query.method === 'telegram' && route.query.type === 'register') {
if (route.query.type === 'register') { // if register with email - agreement inside agreement-block
await authStore.registerWithTelegram(tg.initData)
await authStore.loginWithTelegram(tg.initData)
await router.push({ name: 'projects' })
}
if (route.query.method === 'email' && route.query.type === 'register') {
await router.push({ name: 'create_account' })
}
if (route.query.type === 'update') {
await authStore.termsAccepted()
await router.push({ name: 'projects' })
}
}
const agreement = ref([])
const agreement = ref<('terms'| 'consent')[]>([])
onMounted(() => {
const a = sessionStorage.getItem('agreement')
if (a) agreement.value = JSON.parse(a)
})
onUnmounted(() => {
if (!submitAccept.value) sessionStorage.setItem('agreement', JSON.stringify(agreement.value))
})
</script>

View File

@@ -38,8 +38,15 @@
</div>
</div>
<div class="q-pb-md w100" v-if="chat">
<pn-chat-type-item :type="chat.bot_access || 0" :time="chat.bot_access_date"/>
<pn-chat-type-item v-if="chat.restore_date" :type="10" :time="chat.restore_date"/>
<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">
@@ -54,38 +61,14 @@
class="w100"
@click="goUserInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<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">
<div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/>
{{ $t('chat_card__owner') }}
</div>
</q-item-label>
<q-item-label lines="1" v-if="getUserStatus(item) && getUserStatus(item)?.status">
<q-badge :color="getUserStatus(item)?.color">
{{ $t(getUserStatus(item)?.text ?? '') }}
</q-badge>
</q-item-label>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{ item.section1 }}
</q-item-label>
<q-item-label lines="2" caption v-if="item.section3">
{{ item.section3 }}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{ item.section2_1 }}</div>
<div class="text-blue" v-if="item.section2_2">{{ '@' + item.section2_2 }}</div>
</div>
</q-item-label>
</q-item-section>
</pn-user-list-item-body>
</q-item>
</q-list>
</pn-scroll-list>
@@ -102,7 +85,7 @@
import { parseIntString } from 'src/utils/helpers'
import { useUserSection } from 'composables/useUserSection'
import pnChatTypeItem from 'components/pnChatTypeItem.vue'
import { getUserStatus } from 'utils/users'
import pnUserListItemBody from 'components/pnUserListItemBody.vue'
import type { Chat } from 'types/Chat'
import type { WebApp } from '@twa-dev/types'

View File

@@ -13,10 +13,10 @@
</div>
<div class="flex column">
<div class="text-caption" align="center" style="white-space: pre-wrap;">
<div class="text-caption text-center" style="white-space: pre-wrap;">
{{ $t('company_edit__my_company_hint') }}
</div>
<div class="text-caption text-primary cursor-pointer" align="center" @click="goMyCompany">
<div class="text-caption text-primary cursor-pointer text-center" @click="goMyCompany">
{{ $t('company_edit__my_company_href') }}
</div>
</div>
@@ -42,7 +42,7 @@
const company = companyId.value && companiesStore.companyById(companyId.value) || {} as CompanyParams
async function goMyCompany () {
await router.push({ name: 'your_company' })
await router.replace({ name: 'your_company' })
}
async function updateCompany (company: CompanyParams) {

View File

@@ -141,7 +141,7 @@
</pn-scroll-list>
<q-dialog v-model="showDialogHelp">
<q-card class="q-ma-sm w100">
<q-card class="q-ma-sm w100 rounded-card">
<q-card-section class="row items-center q-pb-none">
<span class="text-h6">
<q-icon name="mdi-domino-mask"/>

View File

@@ -1,7 +1,7 @@
<template>
<company-block
v-if="companyMod"
v-model="companyMod"
v-if="company"
:company
title="account_company__title_card"
btnText="account_company__btn"
@update="updateMyCompany"
@@ -18,20 +18,20 @@
const router = useRouter()
const authStore = useAuthStore()
const companyMod = ref<CompanyParams | null>(null)
const company = ref<CompanyParams | null>(null)
async function updateMyCompany () {
if (companyMod.value) {
await authStore.updateMyCompany(companyMod.value)
async function updateMyCompany (company: CompanyParams) {
if (company) {
await authStore.updateMyCompany(company)
router.go(-1)
}
}
onMounted(() => {
if (authStore.customer?.company) {
companyMod.value = authStore.customer?.company as CompanyParams
company.value = authStore.customer?.company as CompanyParams
} else {
companyMod.value = {
company.value = {
name: '',
logo: '',
description: '',

View File

@@ -18,8 +18,7 @@
<div class="flex column w100">
<q-input
v-model="login"
autofocus
v-model.trim="login"
dense
filled
class="q-mb-sm"
@@ -33,7 +32,7 @@
/>
<q-input
v-model="password"
v-model.trim="password"
dense
filled
:label="$t('login__password')"
@@ -62,30 +61,31 @@
<div class="self-end">
<q-btn
@click.prevent="forgotPwd"
flat
no-caps
dense
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"
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
sm
no-caps
class="q-my-md"
flat rounded
sm no-caps
class="q-my-md w100"
color="primary"
@click="createAccount"
>
@@ -97,20 +97,19 @@
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 class="orline w100 text-grey">
<span class="q-mx-sm text-caption">
{{ $t('login__or_continue_as') }}
</span>
</div>
<q-btn
flat
sm
no-caps
flat rounded
sm no-caps
color="primary"
class="q-my-md"
class="q-my-md w100"
@click="handleTelegramLogin"
>
<div class="flex items-center text-blue">
<div class="flex items-center text-primary">
<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"/>
@@ -137,19 +136,17 @@
import { ref, computed, inject, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import baseLogo from 'components/BaseLogo.vue'
import { useI18n } from "vue-i18n"
import { useInputRules } from 'composables/useInputRules'
import { useAuthStore } from 'stores/auth'
import type { WebApp } from '@twa-dev/types'
import { QInput } from 'quasar'
type ValidationRule = (val: string) => boolean | string
const tg = inject('tg') as WebApp
const tgUser = tg.initDataUnsafe.user
const authStore = useAuthStore()
const router = useRouter()
const { t } = useI18n()
const { inputRules } = useInputRules()
const login = ref<string>('')
const password = ref<string>('')
@@ -159,8 +156,8 @@
const passwordInput = ref<InstanceType<typeof QInput>>()
const validationRules = {
email: [(val: string) => /.+@.+\..+/.test(val) || t('login__incorrect_email')] as [ValidationRule],
password: [(val: string) => val.length >= 8 || t('login__password_require')] as [ValidationRule]
email: [inputRules.email],
password: [inputRules.password]
}
const isEmailValid = computed(() =>
@@ -172,7 +169,7 @@
)
const passwordHint = computed(() => {
const result = validationRules.password[0](password.value)
const result = validationRules.password[0] ? validationRules.password[0](password.value) : null
return typeof result === 'string' ? result : ''
})
@@ -213,7 +210,7 @@
async function createAccount() {
sessionStorage.setItem('pendingLogin', login.value)
await router.push({ name: 'agreements', query: { method: 'email', type: 'register' }})
await router.push({ name: 'create_account' })
}
const isTelegramApp = computed(() => {
@@ -226,7 +223,7 @@
await authStore.loginWithTelegram(tg.initData)
await router.push({ name: 'projects' })
} catch {
await router.push({ name: 'agreements', query: { method: 'telegram', type: 'register' }})
await router.push({ name: 'agreements', query: { type: 'register' }})
}
}

View File

@@ -156,6 +156,7 @@
</transition>
</div>
</div>
<pn-onboard-btn
v-if="projects.length===0 && projectsInit"
icon="mdi-briefcase-plus-outline"

View File

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

View File

@@ -38,7 +38,13 @@
/>
</pn-item-btm-dialog>
<q-item-label class="q-mt-md" header>{{ $t('settings__bot_title') }}</q-item-label>
<template v-if="authStore.isAuth">
<q-item-label
class="q-mt-md"
header
>
{{ $t('settings__bot_title') }}
</q-item-label>
<pn-item-btm-dialog
title="settings__language"
@@ -54,6 +60,7 @@
:options="localeOptions"
/>
</pn-item-btm-dialog>
<pn-item-btm-dialog
title="settings__timezone"
caption="settings__bot_title"
@@ -71,6 +78,7 @@
:locale
/>
</pn-item-btm-dialog>
</template>
</q-list>
</pn-scroll-list>
</pn-page-card>
@@ -79,11 +87,13 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
import pnItemBtmDialog from 'components/pnItemBtmDialog.vue'
import pnListSelector from 'components/pnListSelector.vue'
import pnTimeZoneSelector from 'components/pnTimeZoneSelector.vue'
const settingsStore = useSettingsStore()
const authStore = useAuthStore()
const localeOptions = settingsStore.supportLocale

View File

@@ -1,313 +1,162 @@
<!-- eslint-disable @typescript-eslint/ban-ts-comment -->
<!-- eslint-disable @typescript-eslint/ban-ts-comment -->
<!-- eslint-disable @typescript-eslint/ban-ts-comment -->
<template>
<pn-page-card>
<template #title>
{{$t('subscribe__title')}}
</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">
<div class="flex items-center text-grey">
{{ $t('subscribe__current_plan') }}
{{ $t('subscribe__current_tariff_title') }}
</div>
<div class="flex items-center column text-right">
<span class="text-bold">
{{ currentPlanData?.name }}
{{ currentTariff?.tariffInfo.name }}
</span>
</div>
</div>
<div
class="row w100 no-wrap items-center text-caption justify-between"
v-if="currentPlanData.name !== 'TEST'"
v-if="!isCurrentTariffTest"
>
<div class="text-grey text-no-wrap ellipsis">
{{ $t('subscribe__plan_date') }}
{{ $t('subscribe__current_tariff_date') }}
</div>
<span class="text-no-wrap text-right">
{{ date.formatDate(currentPlanData.exp * 1000, 'DD.MM.YYYY') }}
<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>
{{ $t('subscribe__plan_active_chats') }}
{{ $t('subscribe__current_tariff_chat_limit') }}
</div>
<div class="text-right">
<span class="text-brand2 text-bold q-pr-xs">{{ currentPlanData?.active_chats }}</span>
<span>/{{ currentPlanData?.chatsQty }}</span>
<span
class="text-bold q-pr-xs"
:class="currentTariff.tariffInfo.chat_limit && (currentTariff.tariffInfo.chat_limit > currentTariff?.used_chats) ? 'negative' : ''"
>
{{ currentTariff.used_chats }}
</span>
<span v-if="currentTariff.tariffInfo.chat_limit">
/{{ currentTariff.tariffInfo.chat_limit }}
</span>
</div>
</div>
</div>
</template>
<div class="w100 column items-center text-center text-grey q-px-md">
{{ $t('subscribe__plans')}}
<div class="text-caption">
{{ $t('subscribe__plans_description') }}
</div>
</div>
<q-list separator>
<q-item
v-for="item in plans"
:key="item.id"
clickable
v-ripple
tag="label"
:disable="item.name === 'TEST' && currentPlanData.name !== 'TEST'"
>
<q-item-section avatar>
<q-radio
v-model="newPlan"
:val="item"
:disable="item.name === 'TEST' && currentPlanData.name != 'TEST'"
:keep-color="item.name === 'TEST'&& currentPlanData.name !== 'TEST'"
:color="item.name === 'TEST' && currentPlanData.name !== 'TEST' ? 'grey-5' : ''"
<div v-if="tariffs && currentTariff">
<subscribe-upgrade
v-if="isCurrentTariffTest"
:tariffs
:periods
@tariff-upgrade="prepareTgBtn"
/>
</q-item-section>
<q-item-section>
<div class="column">
<span class="text-bold">
{{ item.name }}
</span>
<div class="flex no-wrap items-center text-caption" style="line-height: 1em;">
<span v-if="item.chatsQty">
{{ $t('subscribe__chats_max') + ' ' + item.chatsQty }}
</span>
<q-icon v-else name="mdi-all-inclusive" size="sm"/>
<span class="q-pl-xs">
{{ $t('subscribe__chats')}}
</span>
</div>
<span
v-if="item.name === 'TEST' && currentPlanData.name !== 'TEST'"
class="text-caption text-grey-7"
>
{{ $t('subscribe__TEST_via_support')}}
</span>
</div>
</q-item-section>
<q-item-section
class="text-left"
:style="{ width: `${maxWidthStarsSection}px` }"
>
<q-resize-observer @resize="updateMaxWidthStarsSection" />
<div v-if="item.price" class="flex column justify-start">
<div class="flex no-wrap items-center">
<telegram-star color="gold" size="18px" class="q-mr-xs"/>
<span class="text-h6">{{ formatNumber(item.price) }}</span>
<span class="text-caption q-pl-xs">{{ $t('subscribe__per_month') }}</span>
</div>
</div>
<div v-else class="text-bold self-center">
{{ $t('subscribe__free_tax') }}
</div>
</q-item-section>
</q-item>
</q-list>
<div class="q-px-md">
<div class="w100 text-grey text-center q-pt-sm">
{{ $t('subscribe__plans_interval') }}
</div>
<q-btn-group spread flat class="w100 q-py-sm">
<q-btn
v-for="period in periods"
:key="period.id"
:outline="selectPeriod === period.value"
@click="selectPeriod = period.value"
:color="selectPeriod === period.value ? 'primary' : ''"
no-caps dense
>
<div class="column items-center w100 self-end">
<span
class="text-h6"
:class="selectPeriod === period.value ? 'text-primary' : 'text-black'"
>
{{ $t(period.name) }}
</span>
<div
class="row no-wrap w100 q-px-xs"
:class="period.sale ? 'justify-between' : 'justify-center'"
>
<span
class="text-caption text-grey"
>
{{ $t(period.name_in_days) }}
</span>
<q-badge v-show="period.sale" color="red">-{{ period.sale }}%</q-badge>
</div>
</div>
</q-btn>
</q-btn-group>
<div class="text-caption text-grey">
<span>{{ $t('subscribe__plans_period_notes') + ' ' }}</span>
<span
@click="router.push({ name: 'change_plan_rules' })"
class="text-info cursor-pointer"
>
{{ $t('subscribe__plans_period_notes_more_info') }}
</span>
</div>
<q-input
v-model="promocode"
dense
filled
:label="$t('subscribe__promocode')"
class="q-pt-md"
>
<template #append v-if="promocode !== ''">
<q-btn
round dense flat
color="primary"
icon="mdi-arrow-right-circle-outline"
@click="sendPromocode"
<subscribe-change-renew
v-else
:tariffs
:periods
:currentTariff
@update="prepareTgBtn"
/>
</template>
</q-input>
</div>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { ref, computed, inject, onUnmounted, watch } from 'vue'
import telegramStar from 'components/TelegramStar.vue'
import { useRouter } from 'vue-router'
import { date } from 'quasar'
import { useI18n } from 'vue-i18n'
import { ref, computed, inject, onMounted, onUnmounted } from 'vue'
import { date, colors } from 'quasar'
import subscribeUpgrade from 'components/subscribe/subscribeUpgrade.vue'
import subscribeChangeRenew from 'components/subscribe/subscribeChangeRenew.vue'
import {
getTariffs,
getTariffDiscount
} from 'composables/useSubscription'
import { useAuthStore } from 'stores/auth'
import type { Tariff } from 'types/Tariff'
import type { WebApp } from '@twa-dev/types'
import { colors } from 'quasar'
import { useQuasar } from 'quasar'
const router = useRouter()
const authStore = useAuthStore()
const tg = inject('tg') as WebApp
// @ts-expect-error: get hex text
tg.MainButton.color = colors.getPaletteColor('primary')
const plans = [
{ id: 1, name: 'TEST', val: 'test', price: null, chatsQty: 5 },
{ id: 2, name: 'START', val: 'start', price: 1000, chatsQty: 15 },
{ id: 3, name: 'PRO', val: 'pro', price: 5000, chatsQty: 40 },
{ id: 4, name: 'VIP', val: 'vip', price: 12000, chatsQty: null }
const tariffs = ref<Tariff[] | null>(null)
const discountTariff = ref(null)
const testTariffId = computed(() => (tariffs.value && tariffs.value.find(el => el.price === 0))?.id ?? 1)
const isCurrentTariffTest = computed(() => currentTariff.value?.id === testTariffId.value)
const storeTariff = authStore.customer?.plan
const currentTariff = computed(() => {
if (tariffs.value && storeTariff) {
const tariffInfo = tariffs.value.find((el: Tariff) => el.id === storeTariff.id)
if (tariffInfo) return { ...storeTariff, tariffInfo }
}
return null
})
onMounted(async () => {
tariffs.value = await getTariffs()
discountTariff.value = await getTariffDiscount()
})
const basePeriods = [
{ id: 30, name: 'subscribe__1month', name_in_days: 'subscribe__30days' },
{ id: 91, name: 'subscribe__3months', name_in_days: 'subscribe__91days' },
{ id: 365, name: 'subscribe__1year', name_in_days: 'subscribe__365days' }
] as const
const periods = [
{ id: 1, name: 'subscribe__1month', name_in_days: 'subscribe__30days', value: 30, sale: 0 },
{ id: 2, name: 'subscribe__3months', name_in_days: 'subscribe__91days', value: 91, sale: 5 },
{ id: 3, name: 'subscribe__1year', name_in_days: 'subscribe__365days', value: 365, sale: 15 }
]
const selectPeriod = ref(periods[1]?.value)
const newPlan = ref(plans[0])
interface CurrentPlan {
plan: string
exp: number | null
active_chats: number | null
const periods = computed(() => basePeriods.map(el => {
const discount = discountTariff.value ? discountTariff.value[el.id] : 0
return {
...el,
discount
}
const currentPlan = ref<CurrentPlan>({ // temp, this get from api
plan: plans[1].val,
active_chats: 20,
exp: Date.now() / 1000 + 500000
})
interface CurrentPlanData extends CurrentPlan {
price: number | null
}
const currentPlanData = computed((): CurrentPlanData => ({
active_chats: currentPlan.value.active_chats ?? null,
exp: currentPlan.value.exp ?? null,
...plans.find(el=> el?.val === currentPlan.value.plan)
}))
const { t } = useI18n()
const textBtn = computed(() => {
const prorata = currentPlanData.value.price
? Math.ceil(
currentPlanData.value?.price / 30 *
date.getDateDiff(new Date(currentPlan.value.exp * 1000), Date.now())
)
: 0
const k = 1 - Number(periods.find(el => el.value === selectPeriod.value)?.sale) / 100
const stars = formatNumber(
Number(selectPeriod.value) *
Number(newPlan.value?.price) *
k - prorata
)
const newDateExp = date.addToDate(Date.now(), { months: Number(selectPeriod.value) })
return t(newPlan.value.id !== currentPlanData.value.id ? 'subscribe__pay' : 'subscribe__pay_renew') +
' ⭐' + stars +
' (' + t('subscribe__plan_exp') + ' ' +
date.formatDate(newDateExp, 'DD.MM.YYYY') + ')'
})
function formatNumber (number: string | number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")
interface tgBtnData {
status: 'hide' | 'show'
url?: string
textBtn?: string
}
onUnmounted(() => tg.MainButton.hide())
const currentButtonHandler = ref<(() => void) | null>(null)
watch(textBtn, () => tg.MainButton.setText(textBtn.value),
{ immediate: true })
function defaultTgBtn () {
tg.MainButton.setText('-')
tg.MainButton.hide()
watch(newPlan, () =>
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
{ newPlan.value.name === 'TEST' ? tg.MainButton.hide() : tg.MainButton.show() },
{ immediate: true })
const maxWidthStarsSection = ref(0)
interface sizeParams {
height: number,
width: number
}
function updateMaxWidthStarsSection (size: sizeParams) {
if (size.width > maxWidthStarsSection.value) {
maxWidthStarsSection.value = size.width
if (currentButtonHandler.value) {
tg.MainButton.offClick(currentButtonHandler.value)
currentButtonHandler.value = null
}
}
const promocode = ref('')
function prepareTgBtn (btn: tgBtnData) {
defaultTgBtn()
function sendPromocode () {
promocode.value = ''
showNotify('fail')
}
const $q = useQuasar()
const showNotify = (message: 'success' | 'fail') => {
$q.notify({
message: t(message === 'success' ? 'subscribe__promocode_success' : 'subscribe__promocode_fail'),
type: message === 'success' ? 'positive' : 'negative',
position: 'bottom',
timeout: 1000,
multiLine: true
})
if (btn.status === 'show' && btn.textBtn && btn.url) {
tg.MainButton.setText(btn.textBtn)
tg.MainButton.show()
const handler = () => tg.openInvoice(btn.url!)
currentButtonHandler.value = handler
tg.MainButton.onClick(handler)
}
}
onUnmounted(() => defaultTgBtn())
</script>
<style lang="scss">

View File

@@ -42,8 +42,8 @@
import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp
const supportEmailAddress = 'a-mart@ya.ru'
const supportTelegramUser = 'alexmart80'
const supportEmailAddress = 'support@tgcrew.ru'
const supportTelegramUser = 'tgCrewSupport'
const telegramUrl = `https://t.me/${supportTelegramUser}`

View File

@@ -8,14 +8,15 @@
v-model="search"
placeholder="chats__search"
:show-calendar-btn="false"
:filter-active="showFiltersDialog"
:filter-badge="!checkFiltersSelect"
@open-filters="showFiltersDialog = true"
:show-filter-btn="false"
/>
</template>
<template #card-body-btn>
<div class="w100 flex justify-end q-px-md">
<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
@@ -66,34 +67,106 @@
<q-item-section side>
<div class="row no-wrap items-center">
<span class="text-caption q-pr-xs">
{{ showDate(item.bot_access_date) }}
{{ getDate(item.bot_access_date) }}
</span>
<q-icon
:name="getChatType(item.bot_access).icon"
:color="getChatType(item.bot_access).color"
: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.restore_date"
:name="getChatType('reconnect').icon"
:color="getChatType('reconnect').color"
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 && chatsInit"
v-if="chats.length === 0 && obsoleteChats.length === 0 && chatsInit"
icon="mdi-chat-plus-outline"
:message1="$t('chats__onboard_msg1')"
:message2="$t('chats__onboard_msg2')"
@@ -123,6 +196,7 @@
v-if="showFab"
icon="add"
color="brand"
:class="isEnableChatAdd ? '' : 'fab-disabled'"
direction="up"
vertical-actions-align="right"
@click="showOverlay = !showOverlay"
@@ -138,7 +212,7 @@
>
<template #icon>
<q-list class="q-py-sm">
<q-list class="q-py-sm" v-if="isEnableChatAdd">
<q-item
v-for="item in fabMenu"
:key="item.id"
@@ -176,6 +250,14 @@
</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>
@@ -204,12 +286,11 @@
>
<div class="q-py-none q-px-md q-ma-none">
<q-list class="q-pt-sm">
<pn-chat-type-item
v-for="item in [0, 1, 2, 9, 10] as const"
:key="item"
:type="item"
class="q-px-none"
/>
<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">
@@ -226,70 +307,21 @@
</div>
</pn-simple-scroll-dialog>
<!-- filter dialog -->
<pn-bottom-sheet-dialog
title="chats__filters"
v-model="showFiltersDialog"
>
<template #btnSlot>
<div>
<q-btn
v-if="!checkFiltersSelect"
@click="resetFilters"
flat
no-caps
dense
color="grey-6"
>
{{ $t('chats__filters_reset')}}
</q-btn>
</div>
</template>
<template #footer>
<q-btn
rounded
class="w100"
color="primary"
@click="showFiltersDialog = false"
>
{{$t('chats__filters_continue')}}
</q-btn>
</template>
<div class="q-pl-sm text-bold text-caption">
{{ $t('chats__filters_type') }}
</div>
<div class="flex column">
<div
v-for="(item,idx) in chatTypeOptions"
:key="idx"
>
<q-checkbox
v-model="filters.chatType"
:val="item.value"
>
{{ $t(item.label) }}
</q-checkbox>
</div>
</div>
</pn-bottom-sheet-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount, inject } from 'vue'
import { useAuthStore } from 'stores/auth'
import { useChatsStore } from 'stores/chats'
import type { WebApp } from '@twa-dev/types'
import type { Chat, ChatType } from 'types/Chat'
import type { Chat } 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 pnBottomSheetDialog from 'components/pnBottomSheetDialog.vue'
import { getChatType } from 'utils/chats'
import { chatAccess, chatState } from 'utils/chats'
const router = useRouter()
const route = useRoute()
@@ -312,6 +344,15 @@
reset: () => void
}
const authStore = useAuthStore()
const isEnableChatAdd = computed(() => {
if (!authStore.customer?.plan) return false
const a = authStore.customer?.plan?.chat_limit
if (a === null) return true
const b = authStore.customer?.plan?.used_chats
return a >= b
})
const chats = computed(() => chatsStore.chats)
const chatsInit = computed(() => chatsStore.isInit)
@@ -327,7 +368,6 @@
const displayChats = computed(() => {
return chats.value
.filter(searchChats)
.filter(chatsType)
function searchChats (el: Chat) {
if (!search.value || !(search.value && search.value.trim())) return true
@@ -335,11 +375,6 @@
return el.name.toLowerCase().includes(searchValue) ||
el.description && el.description.toLowerCase().includes(searchValue)
}
function chatsType (el: Chat) {
if (filters.value.chatType.length === 0) return true
return filters.value.chatType.includes(el.bot_access)
}
})
function handleSlide (event: SlideEvent, id: number) {
@@ -431,40 +466,15 @@
if (timerId.value) clearTimeout(timerId.value)
})
// filter chat
const showFiltersDialog = ref(false)
interface Filters {
chatType: number[]
}
const defaultFilters = {
chatType: []
}
const filters = ref<Filters>({ ...defaultFilters })
const checkFiltersSelect = computed(() => (
Object.values(filters.value).every(el => el.length === 0)
))
function resetFilters() {
(Object.keys(filters.value) as (keyof Filters)[]).forEach(key => filters.value[key] = [])
}
const chatTypeOptions = computed(() => {
return [2, 1, 0, 9].map(el => ({
id: el,
value: el,
label: getChatType(el as Partial<ChatType>).label
}))
})
function showDate (d: number) {
function getDate (d: number) {
return new Date(d * 1000).getFullYear() === new Date(Date.now()).getFullYear()
? date.formatDate(d * 1000, 'DD MMM')
: date.formatDate(d * 1000, 'MMM YY')
}
const obsoleteChats = computed(() => chatsStore.obsoleteChats)
const showObsoleteChats = ref(false)
</script>
<style scoped>
@@ -496,4 +506,24 @@
align-self: center;
height: 98%;
}
/* icon for obsolete btn chats */
.rotate-icon-btn {
transition: transform 0.3s;
}
.rotate-icon-btn.rotate-icon :deep(.q-icon) {
transform: rotate(180deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.rotate-icon-btn :deep(.q-icon) {
transform: rotate(0deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.fab-disabled :deep(.q-btn--fab) {
opacity: 0.65
}
</style>

View File

@@ -4,20 +4,12 @@
:hideHeader="users.length === 0"
>
<template #card-body-header>
<div class="flex row q-ma-md justify-between">
<q-input
<pn-action-bar
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('users__search')"
dense filled
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
placeholder="users__search"
:show-calendar-btn="false"
:show-filter-btn="false"
/>
</template>
<q-list separator v-if="users.length !== 0">
@@ -37,32 +29,7 @@
class="w100"
@click="goUserInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-if="getUserStatus(item) && getUserStatus(item)?.status">
<q-badge :color="getUserStatus(item)?.color">
{{ $t(getUserStatus(item)?.text ?? '') }}
</q-badge>
</q-item-label>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="2" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{item.section2_1}}</div>
<div class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</div>
</div>
</q-item-label>
</q-item-section>
<pn-user-list-item-body :item showStatus/>
</q-item>
</q-slide-item>
</q-list>
@@ -102,27 +69,7 @@
:key="item.id"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="1" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{item.section2_1}}</div>
<div class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</div>
</div>
</q-item-label>
</q-item-section>
<pn-user-list-item-body :item/>
</q-item>
</q-list>
</transition>
@@ -130,7 +77,6 @@
</div>
<!-- END LEAVE USERS SECTION -->
<!-- BLOCKED USERS SECTION -->
<div
v-if="blockedUsers.length!==0"
@@ -169,27 +115,7 @@
@click="handleUnblockUser(item.id)"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="2" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{item.section2_1}}</div>
<div class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</div>
</div>
</q-item-label>
</q-item-section>
<pn-user-list-item-body :item/>
</q-item>
</q-list>
</transition>
@@ -246,7 +172,8 @@
import { useUsersStore } from 'stores/users'
import { useCompaniesStore } from 'stores/companies'
import { useUserSection } from 'composables/useUserSection'
import { getUserStatus } from 'utils/users'
import pnUserListItemBody from 'components/pnUserListItemBody.vue'
import pnActionBar from 'components/pnActionBar.vue'
defineOptions({ inheritAttrs: false })

View File

@@ -33,7 +33,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE)
})
const publicPath = ['404', 'terms', 'privacy', 'consent']
const publicPath = ['404', 'terms', 'privacy', 'consent', 'settings']
Router.beforeEach(async (to) => {
if (to.name !== 'telegram_only' && sessionStorage.getItem('isTelegram') === 'false') {
@@ -50,8 +50,9 @@ export default defineRouter(function (/* { store, ssrContext } */) {
return authStore.isAuth ? { name: 'projects', replace: true } : true
if (to.name === 'agreements') return true
if (to.name === 'create_account' && !authStore.isAuth) return true
if (to.name === 'recovery_password' && !authStore.isAuth) return true
if (!authStore.isAuth) return { name: 'login', replace: true }

View File

@@ -138,9 +138,9 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/account/ConsentPage.vue')
},
{
name: 'change_plan_rules',
path: '/change-plan-rules',
component: () => import('pages/account/ChangePlanRules.vue')
name: 'change_tariff_rules',
path: '/change-tariff-rules',
component: () => import('src/pages/account/ChangeTariffRules.vue')
},
{
name: 'support',

View File

@@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'boot/axios'
import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth'
import { useChatsStore } from 'stores/chats'
import { useUsersStore } from 'stores/users'
@@ -12,6 +13,7 @@ export const useSSEStore = defineStore('sse', () => {
const maxReconnectAttempts = 5
const projectsStore = useProjectsStore()
const authStore = useAuthStore()
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
@@ -61,33 +63,30 @@ export const useSSEStore = defineStore('sse', () => {
const setupEventListeners = () => {
if (!eventSource.value) return
eventSource.value.addEventListener('chat-attach', (event) => {
eventSource.value.addEventListener('chat-update', (event) => {
const data = JSON.parse(event.data)
projectsStore.init().catch(() => {}) // for update count of chats on projects page
authStore.updateProfile().catch(() => {}) // for update count of active chats in subscribe
if (data.project_id === currentProjectId.value) {
chatsStore.init().catch(() => {})
usersStore.init().catch(() => {}) // for update users from chats
}
})
eventSource.value.addEventListener('chat-delete', (event) => {
const data = JSON.parse(event.data)
projectsStore.init().catch(() => {}) // for update count of chats on projects page
authStore.updateProfile().catch(() => {}) // for update count of active chats in subscribe
if (data.project_id === currentProjectId.value) {
chatsStore.init().catch(() => {})
usersStore.init().catch(() => {})
usersStore.init().catch(() => {}) // for update users from chats
}
})
eventSource.value.addEventListener('chat-detach', (event) => {
eventSource.value.addEventListener('customer-update', (event) => {
const data = JSON.parse(event.data)
if (data.project_id === currentProjectId.value) {
chatsStore.detach(data.id)
usersStore.init().catch(() => {})
}
})
eventSource.value.addEventListener('chat-update', (event) => {
const data = JSON.parse(event.data)
if (data.project_id === currentProjectId.value) {
chatsStore.init().catch(() => {})
authStore.updateProfile().catch(() => {})
}
})
}

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from 'boot/axios'
import type { CompanyParams } from 'types/Company'
import type { CurrentTariff } from 'types/Tariff'
import { useProjectsStore } from 'stores/projects'
interface Customer {
@@ -12,6 +13,7 @@ interface Customer {
last_name?: string
avatar?: string
company?: CompanyParams
plan?: CurrentTariff
}
interface WSMessage {
@@ -24,7 +26,7 @@ interface WSMessage {
const ENDPOINT_MAP = {
register: '/auth/email/register',
forgot: '/auth/forgot',
forgot: '/auth/email/recovery',
changePwd: '/auth/email/change-password',
changeMethod: '/auth/email/upgrade'
} as const
@@ -104,6 +106,12 @@ export const useAuthStore = defineStore('auth', () => {
const setNewEmailPassword = async (code: string, code2: string, email: string, password: string) =>
await api.post('/auth/email/change-email', { code, code2, email, password })
// for payment
async function updateProfile () {
const { data } = await api.get('/customer/profile')
customer.value = data.data
}
// user data company
const updateMyCompany = async (companyData: CompanyParams) => {
const response = await api.put('/customer/profile', { company: companyData })
@@ -131,6 +139,7 @@ export const useAuthStore = defineStore('auth', () => {
initRegistration,
confirmCode,
setPassword,
updateProfile,
updateMyCompany,
getCodeCurrentEmail,
confirmCurrentEmailCode,

View File

@@ -1,22 +1,35 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'boot/axios'
import type { Chat } from 'types/Chat'
import type { Chat, ObsoleteChat } from 'types/Chat'
import { useProjectsStore } from 'stores/projects'
import { useUsersStore } from 'stores/users'
export const useChatsStore = defineStore('chats', () => {
const projectsStore = useProjectsStore()
const chats = ref<Chat[]>([])
const isInit = ref<boolean>(false)
const obsoleteChats = ref<ObsoleteChat[]>([])
const isInit = ref(false)
const isLoading = ref(false)
const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () {
// guard for double request
if (isLoading.value) return
isLoading.value = true
try {
reset()
const { data } = await api.get('/project/' + currentProjectId.value + '/chat')
chats.value.push(...data.data)
const chatsResponse = await api.get(`/project/${currentProjectId.value}/chat`)
chats.value = chatsResponse.data.data
const obsoleteChatsResponse = await api.get(`/project/${currentProjectId.value}/chat/obsolete`)
obsoleteChats.value = obsoleteChatsResponse.data.data
isInit.value = true
} catch {
reset()
} finally {
isLoading.value = false
}
}
function reset () {
@@ -28,6 +41,8 @@ export const useChatsStore = defineStore('chats', () => {
const { data } = await api.delete('/project/' + currentProjectId.value + '/chat/' + chatId)
chats.value = chats.value.filter(item => item.id !== data.data.id)
const usersStore = useUsersStore()
reset()
await init()
await usersStore.init()
}
@@ -40,30 +55,14 @@ export const useChatsStore = defineStore('chats', () => {
return chats.value.find(el => el.id === id)
}
// attach, update and detach for use in SSE-store
function attach (chat: Chat) {
chats.value.push(chat)
}
function update (chat: Chat) {
const idx = chats.value.findIndex(item => item.id === chat.id)
if (chats.value[idx]) Object.assign(chats.value[idx], chat)
}
function detach (chatId: number) {
chats.value = chats.value.filter(item => item.id !== chatId)
}
return {
chats,
obsoleteChats,
isInit,
init,
reset,
unlink,
getKey,
chatById,
attach,
update,
detach
chatById
}
})

View File

@@ -15,13 +15,23 @@ export const useProjectsStore = defineStore('projects', () => {
const chatsStore = useChatsStore()
const usersStore = useUsersStore()
const companiesStore = useCompaniesStore()
const isLoading = ref(false)
async function init () {
// guard for double request
if (isLoading.value) return
isLoading.value = true
try {
reset()
const { data } = await api.get('/project')
const projectsAPI = data.data
projects.value.push(...projectsAPI)
isInit.value = true
} catch {
reset()
} finally {
isLoading.value = false
}
}
function reset () {

View File

@@ -3,6 +3,7 @@ import { ref, computed, inject } from 'vue'
import { api } from 'boot/axios'
import { useAuthStore } from 'stores/auth'
import { useI18n } from 'vue-i18n'
import { Lang } from 'quasar'
import type { WebApp } from '@twa-dev/types'
interface AppSettings {
@@ -42,6 +43,24 @@ export const useSettingsStore = defineStore('settings', () => {
{ value: 20, label: 'settings__fontsize_large' }
]
const quasarLangMap: Record<string, string> = {
'en-US': 'en-US',
'ru-RU': 'ru'
}
const updateQuasarLang = async (locale: string) => {
const quasarLang = quasarLangMap[locale] || 'en-US'
try {
const langModule = await import(
`../../node_modules/quasar/lang/${quasarLang}.js`
)
Lang.set(langModule.default)
} catch (e) {
console.error('Quasar Error load locale:', quasarLang, e)
}
}
const detectLocale = (): string => {
const localeMap = {
ru: 'ru-RU',
@@ -69,9 +88,10 @@ export const useSettingsStore = defineStore('settings', () => {
)
}
const applyLocale = () => {
const applyLocale = async () => {
if (settings.value.locale && i18nLocale) {
i18nLocale.value = settings.value.locale
await updateQuasarLang(settings.value.locale)
}
}
@@ -104,19 +124,15 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
updateCssVariable()
applyLocale()
await applyLocale()
isInit.value = true
}
const saveSettings = async () => {
await api.put('/customer/settings', settings.value)
}
const updateSettings = async (newSettings: Partial<AppSettings>) => {
settings.value = { ...settings.value, ...newSettings }
updateCssVariable()
applyLocale()
await saveSettings()
await applyLocale()
if (authStore.isAuth) await api.put('/customer/settings', settings.value)
}
return {

View File

@@ -8,15 +8,24 @@ export const useUsersStore = defineStore('users', () => {
const projectsStore = useProjectsStore()
const users = ref<User[]>([])
const isInit = ref<boolean>(false)
const isLoading = ref(false)
const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () {
// guard for double request
if (isLoading.value) return
isLoading.value = true
try {
reset()
const { data } = await api.get(`/project/${currentProjectId.value}/user`)
const usersAPI = data.data
users.value.push(...usersAPI)
users.value = data.data
isInit.value = true
} catch {
reset()
} finally {
isLoading.value = false
}
}
function reset () {
@@ -28,7 +37,7 @@ export const useUsersStore = defineStore('users', () => {
const { data } = await api.put(`/project/${currentProjectId.value}/user/${userId}`, userData)
const userAPI = data.data
const idx = users.value.findIndex(item => item.id === userAPI.id)
if (users.value[idx]) Object.assign(users.value[idx], userAPI)
if (idx !== -1) users.value[idx] = { ...users.value[idx], ...userAPI }
}
async function blockUser (userId: number) {

View File

@@ -5,7 +5,6 @@ interface Chat {
message_id: number
name: string
is_channel: boolean
bot_can_ban: boolean
user_count: number
last_update_time: number
description: string | null
@@ -14,20 +13,24 @@ interface Chat {
bot_access: 0 | 1 | 2
bot_access_date: number
bot_rights: 1 | 2 | 3
restore_date: null | number
state: 1 | 2 | 3 | 4
state_date: null | number
users: number []
last_pts?: number
[key: string]: number | string | boolean | null | number[]
}
type ChatType =
0 | 'detach' |
1 | 'notify' |
2 | 'full' |
9 | 'delete' |
10 | 'reconnect'
interface ObsoleteChat {
id: number
name: string
state: 1 | 2 | 3 | 4
state_date: null | number
telegram_id: string
logo: string | null
[key: string]: number | string | null
}
export type {
Chat,
ChatType
ObsoleteChat
}

32
src/types/Tariff.ts Normal file
View File

@@ -0,0 +1,32 @@
interface Tariff {
id: number
name: string
label: string
chat_limit: number | null
price: number
is_archived: boolean
[key: string]: string | boolean | number | null
}
interface CurrentTariff {
id: number
can_change: boolean
debt: number
used_chats: number
start_date: number
end_date: number | null
chat_limit: number | null
}
interface TariffPeriod {
id: number
name: string
name_in_days: string
discount: number
}
export type {
Tariff,
CurrentTariff,
TariffPeriod
}

View File

@@ -1,50 +1,84 @@
import type { ChatType } from "types/Chat"
function getChatType (type: ChatType) {
switch (type) {
default:
case 0:
case 'detach': return {
icon: 'mdi-link-off',
color: 'negative',
label: 'chat_type__detach_label',
description: 'chat_type__detach_description'
function chatAccess (i: 0 | 1 | 2 ) {
switch (i) {
case 0: return typeDescription('no_access')
case 1: return typeDescription('notify')
case 2: return typeDescription('full')
}
}
function chatState (i: 1 | 2 | 3 | 4) {
switch (i) {
case 1: return typeDescription('new')
case 2: return typeDescription('reconnect')
case 3: return typeDescription('detach')
case 4: return typeDescription('delete')
}
}
type ChatType = 'no_access' | 'notify' | 'full' | 'new' | 'reconnect' | 'detach' | 'delete'
function typeDescription (type: ChatType) {
switch (type) {
case 'no_access': return {
info: 'no_access',
icon: 'mdi-close',
color: 'negative',
label: 'chat_type__label_no_access',
description: 'chat_type__description_no_access'
}
case 1:
case 'notify': return {
info: type,
icon: 'mdi-bell-outline',
color: 'info',
label: 'chat_type__notify_label',
description: 'chat_type__notify_description'
label: 'chat_type__label_notify',
description: 'chat_type__description_notify'
}
case 2:
case 'full': return {
icon: 'mdi-star-shooting-outline',
info: type,
icon: 'mdi-crown-outline',
color: 'primary',
label: 'chat_type__full_label',
description: 'chat_type__full_description'
label: 'chat_type__label_full',
description: 'chat_type__description_full'
}
case 9:
case 'delete': return {
icon: 'mdi-forum-remove-outline',
color: 'negative',
label: 'chat_type__delete_label',
description: 'chat_type__delete_description'
case 'new': return {
info: type,
icon: '',
color: 'primary',
label: 'chat_type__label_new',
description: 'chat_type__description_new'
}
case 10:
case 'reconnect': return {
info: type,
icon: 'mdi-repeat-variant',
color: 'negative',
label: 'chat_type__reconnect_label',
description: 'chat_type__reconnect_description'
label: 'chat_type__label_reconnect',
description: 'chat_type__description_reconnect'
}
case 'detach': return {
info: type,
icon: 'mdi-link-off',
color: 'negative',
label: 'chat_type__label_detach',
description: 'chat_type__description_detach'
}
case 'delete': return {
info: type,
icon: 'mdi-chat-remove-outline',
color: 'negative',
label: 'chat_type__label_delete',
description: 'chat_type__description_delete'
}
}
}
export {
getChatType
chatAccess,
chatState
}

View File

@@ -4,17 +4,35 @@ function parseIntString (s: string | string[] | undefined) :number | null {
return regex.test(s) ? Number(s) : null
}
const removeNullKeys = <T extends object>(obj: T): Partial<T> =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== null)
) as Partial<T>
type RemoveNullKeysReturn<T, K extends keyof T = never> =
Partial<Pick<T, Exclude<keyof T, K>>> & Pick<T, K>
const removeNullKeys = <T extends object, K extends keyof T = never>(
obj: T,
exceptions?: readonly K[]
): RemoveNullKeysReturn<T, K> => {
const result: Partial<T> = {}
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (exceptions?.includes(key as K) || value !== null) {
result[key] = value
}
}
return result as RemoveNullKeysReturn<T, K>
}
const getFieldValue = (mod: string, props: undefined | null | string ) => {
return mod !== '' ? mod : props === null || props === undefined ? null : ''
}
const formatNumber = (number: string | number) => {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")
}
export {
parseIntString,
removeNullKeys,
getFieldValue
getFieldValue,
formatNumber
}