This commit is contained in:
Nadar
2026-03-17 13:24:22 +03:00
commit 82e5ac9d81
554 changed files with 29637 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.idea
.turbo
.git
.gitab-ci.d
.gitlab-ci.yml
out
**/node_modules
Dockerfile.*

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
out/
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
# vercel
.vercel
/.idea/git_toolbox_prj.xml
/.idea/vcs.xml

View File

@@ -0,0 +1,45 @@
build-app-client-cf-pages:
stage: build
needs: []
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
image:
name: gcr.io/kaniko-project/executor:v1.15.0-debug
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.client"
--target builder-cloudflare
--skip-unused-stages=true
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/frontend-app-client/cache"
--no-push
deploy-app-client-cf-pages:
stage: deploy
needs: []
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
changes:
- apps/client/**/*
- layers/ui/**/*
- layers/shared/**/*
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
when: manual
image:
name: gcr.io/kaniko-project/executor:v1.15.0-debug
entrypoint: [""]
script:
- cp ${CLOUDFLARE_PRGMS_IO_ACCOUNT_ID} /kaniko/cloudflare-account-id
- cp ${CLOUDFLARE_PRGMS_IO_WRANGLER_API_TOKEN} /kaniko/cloudflare-api-token
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.client"
--target deployer-cloudflare
--skip-unused-stages=true
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/frontend-app-client/cache"
--no-push

View File

@@ -0,0 +1,89 @@
build-app-client-docker:
stage: build
needs: []
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
image:
name: gcr.io/kaniko-project/executor:v1.15.0-debug
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.client"
--target runner-nodejs
--skip-unused-stages=true
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/frontend-app-client/cache"
--no-push
build-app-client-docker-latest:
stage: build
needs: []
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
changes:
- apps/client/**/*
- layers/ui/**/*
- layers/shared/**/*
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
when: manual
image:
name: gcr.io/kaniko-project/executor:v1.15.0-debug
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.client"
--target runner-nodejs
--skip-unused-stages=true
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/frontend-app-client/cache"
--destination "${CI_REGISTRY_IMAGE}/frontend-app-client:latest"
--image-name-with-digest-file "${CI_PROJECT_DIR}/.images/frontend-app-client.txt"
artifacts:
paths:
- .images/frontend-app-client.txt
deploy-app-client-docker:
image: alpine:latest
stage: deploy
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
needs:
- job: build-app-client-docker-latest
artifacts: true
timeout: 3m
variables:
DOCKER_RUN_COMMAND:
expand: true
value: >-
docker run
--name frontend-app
--detach
--restart unless-stopped
--network frontend
-e NITRO_SHUTDOWN=true
-e NICONSOLE_LEVEL=4
-e CONSOLA_LEVEL=4
-e NITRO_API_HOST=https://api.prgms.io/api/v1
"$(cat frontend-app-client.txt)"
before_script:
- apk add openssh-client
- eval $(ssh-agent -s)
- chmod 400 "$ID_RSA"
- ssh-add "$ID_RSA"
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- cp "$SSH_KNOWN_HOSTS" ~/.ssh/known_hosts
script:
- test -f .images/frontend-app-client.txt
- scp .images/frontend-app-client.txt ${SERVER_USER}@${SERVER_IP}:frontend-app-client.txt
- >-
ssh
"$SERVER_USER@$SERVER_IP"
"docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY"
- ssh "${SERVER_USER}@${SERVER_IP}" "docker stop frontend-app || true"
- ssh "${SERVER_USER}@${SERVER_IP}" "docker rm frontend-app || true"
- ssh "${SERVER_USER}@${SERVER_IP}" "${DOCKER_RUN_COMMAND}"

View File

@@ -0,0 +1,45 @@
build-app-pay-cf-pages:
stage: build
needs: []
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
image:
name: gcr.io/kaniko-project/executor:v1.15.0-debug
entrypoint: [""]
script:
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.pay"
--target builder-cloudflare
--skip-unused-stages=true
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/frontend-app-pay/cache"
--no-push
deploy-app-pay-cf-pages:
stage: deploy
needs: []
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
changes:
- apps/pay/**/*
- layers/ui/**/*
- layers/shared/**/*
- if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "ci/pay"
when: manual
image:
name: gcr.io/kaniko-project/executor:v1.15.0-debug
entrypoint: [""]
script:
- cp ${CLOUDFLARE_PRGMS_IO_ACCOUNT_ID} /kaniko/cloudflare-account-id
- cp ${CLOUDFLARE_PRGMS_IO_WRANGLER_API_TOKEN} /kaniko/cloudflare-api-token
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile.pay"
--target deployer-cloudflare
--skip-unused-stages=true
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/frontend-app-pay/cache"
--no-push

52
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,52 @@
stages:
- build
- deploy
include:
- local: .gitlab-ci.d/app-client-docker.yml
- local: .gitlab-ci.d/app-client-cf-pages.yml
- local: .gitlab-ci.d/app-pay-cf-pages.yml
report-deploy-client-success: &notificationJob
image: alpine:latest
stage: .post
when: on_success
needs:
- deploy-app-client-docker
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "ci/pay"
before_script:
- apk add curl
variables:
NOTIFICATION_TEXT: "Frontend successfully deployed, check at https://app.prgms.io"
script:
- >-
curl -s -S -X POST
"https://api.telegram.org/bot${TELEGRAM_DEPLOY_NOTIFY_TOKEN}/sendMessage"
-d "chat_id=${TELEGRAM_DEPLOY_NOTIFY_CHAT_ID}"
-d text="$NOTIFICATION_TEXT"
report-deploy-client-failure:
<<: *notificationJob
when: on_failure
needs:
- deploy-app-client-docker
variables:
NOTIFICATION_TEXT: "Frontend deployment failed, check pipeline at ${CI_PIPELINE_URL}"
report-deploy-pay-success:
<<: *notificationJob
when: on_success
needs:
- deploy-app-pay-cf-pages
variables:
NOTIFICATION_TEXT: "Payment page successfully deployed, check at https://pay.prgms.io"
report-deploy-pay-failure:
<<: *notificationJob
when: on_failure
needs:
- deploy-app-pay-cf-pages
variables:
NOTIFICATION_TEXT: "Payment page deployment failed, check pipeline at ${CI_PIPELINE_URL}"

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

6
.idea/copilot.data.migration.agent.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/git_toolbox_blame.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxBlameSettings">
<option name="version" value="2" />
</component>
</project>

14
.idea/indefiti.iml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/apps/client/.output" />
<excludeFolder url="file://$MODULE_DIR$/apps/client/.turbo" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,23 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="7">
<item index="0" class="java.lang.String" itemvalue="nobr" />
<item index="1" class="java.lang.String" itemvalue="noembed" />
<item index="2" class="java.lang.String" itemvalue="comment" />
<item index="3" class="java.lang.String" itemvalue="noscript" />
<item index="4" class="java.lang.String" itemvalue="embed" />
<item index="5" class="java.lang.String" itemvalue="script" />
<item index="6" class="java.lang.String" itemvalue="uiiconbell" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
<inspection_tool class="SassScssResolvedByNameOnly" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

6
.idea/jsLinters/eslint.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/indefiti.iml" filepath="$PROJECT_DIR$/.idea/indefiti.iml" />
</modules>
</component>
</project>

4
.idea/watcherTasks.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
auto-install-peers = true

7
.prettierrc.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
endOfLine: 'auto',
trailingComma: 'es5',
tabWidth: 2,
semi: true,
singleQuote: true,
};

46
Dockerfile.client Normal file
View File

@@ -0,0 +1,46 @@
FROM node:18-alpine AS base
WORKDIR /app
RUN yarn global add turbo@1.5.5
FROM base AS pruner
COPY . .
RUN test -f package.json
RUN turbo prune --scope=client --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS deps
COPY --link --from=pruner /app/out/json/ .
COPY --link --from=pruner /app/out/yarn.lock ./yarn.lock
RUN yarn install --frozen-lockfile
COPY --from=pruner /app/out/full .
FROM deps AS builder-cloudflare
ENV NITRO_PRESET=cloudflare-pages
RUN turbo run build --scope=client
FROM node:18-alpine AS deployer-cloudflare
WORKDIR /app
RUN npm install --global wrangler
COPY --link --from=builder-cloudflare /app/apps/client/dist /app/dist
ARG GIT_BRANCH="main" \
GIT_COMMIT="" \
CF_PAGES_PROJECT="app-prgms-io"
RUN --mount=type=secret,id=cloudflare-account-id,target=/kaniko/cloudflare-account-id \
--mount=type=secret,id=cloudflare-api-token,target=/kaniko/cloudflare-api-token \
CLOUDFLARE_ACCOUNT_ID=$(cat /kaniko/cloudflare-account-id) \
CLOUDFLARE_API_TOKEN=$(cat /kaniko/cloudflare-api-token) \
wrangler pages deploy \
--project-name "${CF_PAGES_PROJECT}" \
--branch "${GIT_BRANCH}" \
--commit-hash "${GIT_COMMIT}" \
dist
FROM deps AS builder-nodejs
ENV NITRO_PRESET=node-server
RUN turbo run build --scope=client
FROM node:18-alpine AS runner-nodejs
WORKDIR /app
COPY --from=builder-nodejs /app/apps/client/.output /app
EXPOSE 3000
CMD [ "node", "./server/index.mjs" ]

45
Dockerfile.pay Normal file
View File

@@ -0,0 +1,45 @@
FROM node:18-alpine AS base
WORKDIR /app
RUN yarn global add turbo@1.5.5
FROM base AS pruner
COPY . .
RUN test -f package.json
RUN turbo prune --scope=pay --docker
FROM base as deps
COPY --link --from=pruner /app/out/json/ .
COPY --link --from=pruner /app/out/yarn.lock ./yarn.lock
RUN yarn install --frozen-lockfile
COPY --from=pruner /app/out/full .
FROM deps AS builder-cloudflare
ENV NITRO_PRESET=cloudflare-pages
RUN turbo run build --scope=pay
FROM node:18-alpine AS deployer-cloudflare
WORKDIR /app
RUN npm install --global wrangler
COPY --link --from=builder-cloudflare /app/apps/pay/dist /app/dist
ARG GIT_BRANCH="main" \
GIT_COMMIT="" \
CF_PAGES_PROJECT="pay-prgms-io"
RUN --mount=type=secret,id=cloudflare-account-id,target=/kaniko/cloudflare-account-id \
--mount=type=secret,id=cloudflare-api-token,target=/kaniko/cloudflare-api-token \
CLOUDFLARE_ACCOUNT_ID=$(cat /kaniko/cloudflare-account-id) \
CLOUDFLARE_API_TOKEN=$(cat /kaniko/cloudflare-api-token) \
wrangler pages deploy \
--project-name "${CF_PAGES_PROJECT}" \
--branch "${GIT_BRANCH}" \
--commit-hash "${GIT_COMMIT}" \
dist
FROM deps AS builder-nodejs
ENV NITRO_PRESET=node-server
RUN turbo run build --scope=pay
FROM node:18-alpine AS runner-nodejs
WORKDIR /app
COPY --from=builder-nodejs /app/apps/pay/.output /app
EXPOSE 3000
CMD [ "node", "./server/index.mjs" ]

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# Cryptokittens Front
## Структура проекта
- `apps`
- `client` - основное приложение
- `pay` - страница оплаты
- `admin` - админка (возможно когда-то будет?)
- `packages`
- `ui`- библиотека UI компонентов
- `eslint-config-custom` - конфигурация для ESLint и Prettier
## Режим разработчика
```sh
yarn
yarn dev:client
```
## Сборка проекта
```sh
yarn
yarn build:client
```
Итоговый код будет доступен по пути `/apps/client/.output`

24
apps/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

2
apps/client/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false

13
apps/client/app.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<NuxtLayout>
<NuxtLoadingIndicator :height="4" />
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
useHead({
title: 'Indefiti',
meta: [{ name: 'robots', content: 'noindex,nofollow' }],
})
</script>

View File

@@ -0,0 +1,7 @@
body, html {
height: 100%;
}
#__nuxt {
height: 100%;
}

View File

@@ -0,0 +1,165 @@
<template>
<div class="asset-card" :class="{ 'is-active': isActive, 'is-disabled': disabled }">
<UiCoin
class="asset-card__coin"
:code="code"
/>
<div class="asset-card__name-wrapper">
<p class="asset-card__code">
{{ code }}
</p>
<p class="asset-card__name">
{{ name }}
</p>
</div>
<div class="asset-card__money">
<p class="asset-card__balance">
<span
v-if="showConverted"
class="asset-card__converted"
>
{{ convertedBalance }}
</span>
<span>{{ balance }}</span>
</p>
<p
v-if="withdraw"
class="asset-card__withdraw"
>
<span
v-if="showConverted"
class="asset-card__converted"
>
{{ convertedWithdraw }}
</span>
<span>{{ withdraw }}</span>
<UiIconSUpRight class="asset-card__withdraw-icon" />
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type Decimal from 'decimal.js'
interface Props {
code: string
name?: string
balance: Decimal.Value
withdraw?: Decimal.Value
rate: Decimal.Value
isActive?: boolean
disabled?: boolean
}
const props = defineProps<Props>()
const showConverted = computed(() => !!props.rate && props.rate !== 1)
const convertedBalance = computed(() => $money.fullFormat($money.convert(props.balance, props.rate), 'USDT'))
const convertedWithdraw = computed(() => $money.fullFormat($money.convert(props.balance, props.rate), 'USDT'))
</script>
<style lang="scss">
.asset-card {
display: grid;
grid-template-columns: auto 1fr 1fr;
align-items: center;
padding: 24px;
border-radius: 12px;
background-color: $clr-grey-100;
outline: 2px solid transparent;
outline-offset: -2px;
cursor: pointer;
transition: .2s ease-out;
transition-property: outline-color, background-color;
min-height: 97px;
&:hover {
outline-color: $clr-grey-300;
}
&.is-active,
&:active {
background-color: $clr-grey-200;
}
&.is-active {
outline-color: $clr-cyan-300;
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
&__name-wrapper {
min-width: 0;
}
&__coin {
margin-right: 16px;
}
&__code {
@include txt-l-sb;
text-transform: uppercase;
}
&__name {
@include txt-r-m;
margin-top: 2px;
color: $clr-grey-400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__money {
text-align: right;
}
&__balance {
@include txt-l-sb;
}
&__withdraw {
@include txt-i-sb;
margin-top: 8px;
}
&__balance,
&__withdraw {
vertical-align: middle;
white-space: nowrap;
span {
vertical-align: middle;
}
}
&__withdraw-icon {
color: $clr-cyan-500;
margin-left: 4px;
}
&__converted {
@include txt-i-m;
vertical-align: middle;
color: $clr-grey-400;
&::after {
content: '/';
margin-inline: 4px;
color: $clr-grey-300;
}
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<Sidebar id="asset" class="asset-sidebar" @close="$emit('close')">
<UiCoin :code="asset.code" class="asset-sidebar__coin" />
<div>
<MoneyAmount class="asset-sidebar__balance" :value="asset.balance" :currency="asset.code" />
</div>
<div class="asset-sidebar__actions">
<UiButton type="ghost" size="small" icon="s-up-right" :href="`/create/withdraw/${projectId}/${asset.code}`">
Отправить
</UiButton>
<UiButton type="ghost" size="small" icon="s-down-left" :href="`/create/invoice/${projectId}`">
Получить
</UiButton>
<UiButton type="ghost" color="secondary" size="small" icon="s-exchange" disabled>
Обменять
</UiButton>
</div>
</Sidebar>
</template>
<script setup lang="ts">
import { Sidebar } from '#components'
defineProps({
projectId: {
type: String,
required: true,
},
asset: {
type: Object,
required: true,
},
})
defineEmits(['close'])
</script>
<style lang="scss">
.asset-sidebar {
text-align: center;
&__coin {
border-radius: 9px;
height: 48px;
width: 48px;
margin-bottom: 16px;
}
&__balance {
@include h3('money-amount-value', true);
}
&__actions {
--button-ghost-primary-color: #{$clr-black};
--button-icon-color: #{$clr-cyan-500};
--button-icon-disabled-color: #{$clr-grey-400};
display: flex;
padding: 4px;
background-color: $clr-grey-100;
border-radius: 12px;
margin-top: 40px;
> * {
flex: 1;
}
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="email-confirmation">
<img src="/email-confirmation.svg" alt="logo" draggable="false">
<h1 class="email-confirmation__title">
{{ $t('check_email') }}
</h1>
<div class="email-confirmation__content">
<i18n-t
scope="global"
keypath="we_have_sent_you_an_email_to"
tag="p"
class="email-confirmation__text"
>
<strong class="email-confirmation__email">{{ email }}</strong>
</i18n-t>
<div class="email-confirmation__instructions">
<slot />
</div>
</div>
<div class="email-confirmation__upper-text">
<i18n-t class="mb-18" keypath="check_spam_folder" tag="p" scope="global">
<strong class="text-grey-600">«{{ $t('spam') }}»</strong>
</i18n-t>
</div>
<div v-if="resendFn" class="email-confirmation__resend">
<span>{{ $t('did_not_get_mail') }}</span>
<UiButton class="ml-4" type="link" @click="resendFn">
{{ $t('send_again') }}
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
email: {
required: true,
type: String,
},
resendFn: { type: Function },
})
</script>
<style lang="scss">
.email-confirmation {
width: 516px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 0 auto;
&__title {
margin-bottom: 30px;
}
&__content {
background-color: $clr-grey-200;
padding: 24px;
border-radius: 12px;
}
&__text {
@include txt-l;
display: inline-block;
text-align: center;
margin-bottom: 20px;
width: 100%;
}
&__instructions {
@include txt-l-sb;
display: inline-block;
text-align: center;
width: 100%;
}
&__email {
color: $clr-grey-500;
}
&__upper-text {
@include txt-m;
display: inline-block;
text-align: center;
margin-top: 40px;
color: $clr-grey-500;
}
&__resend {
@include txt-i-m;
color: $clr-grey-600;
}
&__under-text {
@include txt-i-m;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<UiAccordion>
<UiAccordionItem
:title="$t('forgot_password_questions.forgot_password.title')"
>
<i18n-t
scope="global"
keypath="forgot_password_questions.forgot_password.content"
tag="p"
>
<UiButton type="link" href="/reset-password">
{{ $t('can_reset_password') }}
</UiButton>
<UiButton type="link">
{{ $t('write_us') }}
</UiButton>
</i18n-t>
</UiAccordionItem>
<UiAccordionItem
:title="$t('forgot_password_questions.access_to_mail.title')"
>
<i18n-t
scope="global"
keypath="forgot_password_questions.access_to_mail.content"
tag="p"
>
<UiButton type="link">
{{ $t('fill_the_form') }}
</UiButton>
</i18n-t>
</UiAccordionItem>
<UiAccordionItem :title="$t('forgot_password_questions.no_mail.title')">
<i18n-t
keypath="forgot_password_questions.no_mail.content"
tag="p"
scope="global"
>
<UiButton type="link">
{{ $t('write_us') }}
</UiButton>
</i18n-t>
</UiAccordionItem>
</UiAccordion>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,131 @@
<template>
<div
:class="[
cn.b(),
cn.is('checked', checked),
cn.is('invalid', invalid),
cn.is('disabled', disabled),
cn.is('focused', focused),
]"
@click="handleChange"
>
<UiIconSCheck :class="[cn.e('checkmark')]" />
<p :class="[cn.e('label')]">
<slot v-bind="{ checked }">
{{ label }}
</slot>
</p>
</div>
</template>
<script setup lang="ts">
export interface Props {
id: string
label?: string
disabled?: boolean
modelValue?: boolean | string | number
trueValue?: boolean | string | number
falseValue?: boolean | string | number
required?: boolean
}
defineOptions({
name: 'CheckboxButton',
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
trueValue: true,
falseValue: false,
required: false,
modelValue: undefined,
})
const slots = useSlots()
const { checked, invalid, focused, handleChange } = useCheckbox(props, slots)
const cn = useClassname('checkbox-button')
</script>
<style lang="scss">
.checkbox-button {
$self: &;
@include txt-i-m;
--border-color: #{$clr-grey-300};
--border-width: 1px;
display: inline-flex;
align-items: center;
height: 40px;
padding: 8px 16px;
border-radius: 12px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
cursor: pointer;
transition: .2s ease-out;
transition-property: outline-color, background-color, color;
user-select: none;
&:hover {
--border-color: transparent;
background-color: $clr-grey-200;
}
&.is-checked {
--border-color: transparent;
background-color: $clr-cyan-500;
color: $clr-white;
&:hover {
background-color: $clr-cyan-400;
}
}
//&.is-invalid {
// --border-color: var(--checkbox-invalid-border-color);
//}
&.is-disabled {
cursor: not-allowed;
}
&__checkmark {
--border-color: var(--checkbox-border-color);
--border-width: 1px;
width: var(--size);
height: var(--size);
border-radius: 4px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
color: transparent;
cursor: pointer;
transition: .2s ease-out;
transition-property: outline-color, background-color, color;
margin-right: 8px;
#{$self}.has-label & {
margin-top: 1px;
}
#{$self}.is-checked & {
--border-width: 0;
color: var(--checkbox-checked-color);
background-color: var(--checkbox-checked-background);
}
#{$self}.is-disabled & {
--border-color: var(--checkbox-disabled-border-color);
}
#{$self}.is-disabled.is-checked & {
color: var(--checkbox-disabled-checked-color);
}
}
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div
class="currency-name"
:class="`currency-name--${size}`"
>
<UiCoin
class="currency-name__coin"
:code="code"
/>
<span
v-if="size !== 'small'"
class="currency-name__name"
>
{{ name || code }}
</span>
<span class="currency-name__code">{{ code }}</span>
</div>
</template>
<script setup lang="ts">
export interface Props {
code: string
name?: string
size?: 'small' | 'medium'
}
defineOptions({
name: 'CurrencyName',
})
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
})
</script>
<style lang="scss">
.currency-name {
&--medium {
display: grid;
grid-template-columns: 32px auto;
gap: var(--currency-name-gap, 4px 8px);
align-items: center;
}
&--small {
--coin-size: 20px;
display: inline-flex;
vertical-align: middle;
justify-content: flex-end;
align-items: center;
gap: var(--currency-name-gap, 4px);
}
&__coin {
grid-column: 1;
grid-row: span 2;
}
&__name {
@include txt-r-sb('currency-name');
color: $clr-black;
}
&__code {
@include txt-s-m('currency-name-code');
color: $clr-grey-500;
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="form-header">
<UiButton
icon="chevron-left"
color="secondary"
type="link"
class="form-header__back"
:href="backLink"
>
{{ backText }}
</UiButton>
<slot name="title">
<h1 class="form-header__title">
{{ title }}
</h1>
</slot>
</div>
</template>
<script setup>
defineProps({
backLink: { type: String, required: true },
backText: { type: String, required: true },
title: { type: String, required: true },
})
</script>
<style lang="scss">
.form-header {
&__back {
margin-bottom: 8px;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="formatted-date">
<p class="formatted-date__date">
{{ date }} <span class="formatted-date__time">{{ time }}</span>
</p>
<p class="formatted-date__zone">
(UTC+3)
</p>
</div>
</template>
<script setup>
import dayjs from 'dayjs'
const props = defineProps({
value: { type: String, required: true },
})
const dayObj = computed(() => dayjs(props.value))
const date = computed(() => dayObj.value.format('DD.MM.YY'))
const time = computed(() => dayObj.value.format('HH:mm'))
</script>
<style lang="scss">
.formatted-date {
&__date {
@include txt-r-sb;
color: $clr-black;
white-space: nowrap;
}
&__time {
color: $clr-grey-400;
}
&__zone {
@include txt-s-m;
color: $clr-grey-500;
margin-top: 4px;
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="invoices-empty">
<img
src="/flag.svg"
class="mb-40"
>
<h2>
{{ $t('your_invoices_will_be_displayed_here.title') }}
</h2>
<h4 class="invoices-empty__text">
{{ $t('your_invoices_will_be_displayed_here.content') }}
</h4>
<UiButton
size="large"
class="mt-40 w-100"
:href="`/create/invoice/${id}`"
>
{{ $t('create_an_invoice') }}
</UiButton>
</div>
</template>
<script setup>
defineProps({ id: { type: String, required: true } })
defineEmits(['create'])
</script>
<style lang="scss">
.invoices-empty {
display: flex;
align-items: center;
flex-direction: column;
width: 422px;
align-self: center;
justify-self: center;
&__text {
color: $clr-grey-400;
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<NuxtLink
class="nav-item"
:class="{
'nav-item--active': isActive,
}"
:to="to"
>
<slot name="icon">
<Component
:is="resolveComponent(`ui-icon-${icon}`)"
class="nav-item__icon"
/>
</slot>
<span class="nav-item__title">
{{ title }}
</span>
</NuxtLink>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { UiIcon } from '#build/types/ui/icons'
interface Props {
to: string
icon: UiIcon
title: string
matcher?: () => boolean
}
const props = defineProps<Props>()
const isActive = computed(() => (props.matcher ? props.matcher() : false))
</script>
<style lang="scss">
.nav-item {
--link-color: #{$clr-white};
@include txt-i-sb;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
color: var(--link-color);
transition: color 0.2s ease-out;
&:hover {
--link-color: #{$clr-grey-500};
}
&--active,
&:active {
--link-color: #{$clr-cyan-300} !important;
}
&__icon {
margin-bottom: 5px;
position: relative;
}
&__notification-icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20px;
width: 20px;
height: 20px;
top: -8px;
right: -24px;
background: $clr-white;
color: $clr-white;
}
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<NuxtLink to="/projects">
<img
class="app-logo__img"
src="/logo-with-text.svg"
alt="logo"
draggable="false"
>
</NuxtLink>
</template>

View File

@@ -0,0 +1,46 @@
<template>
<nav class="nav-sidebar">
<div
v-if="$slots.logo"
class="nav-sidebar__logo"
>
<slot name="logo" />
</div>
<div class="nav-sidebar__content">
<slot />
</div>
<div class="nav-sidebar__bottom">
<slot name="bottom" />
</div>
</nav>
</template>
<style lang="scss">
.nav-sidebar {
display: flex;
flex-direction: column;
background: #{$clr-black};
color: #{$clr-white};
padding: 32px 27px 32px 27px;
width: 120px;
height: 100%;
text-align: center;
overflow: hidden;
&__logo {
margin-bottom: 60px;
}
&__content {
display: flex;
flex-direction: column;
overflow-y: auto;
gap: 20px;
margin-inline: -27px;
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div
class="network-select network-select--secondary network-select--large"
:class="{
'has-value': !!field.value.value,
'is-disabled': disabled,
'is-invalid': invalid,
}"
v-bind="$attrs"
@click="show"
>
<label
ref="wrapper"
class="network-select__wrapper"
tabindex="0"
@keydown.enter="show"
@keydown.space="show"
>
<span class="network-select__content">
<div
v-if="!!field.value.value"
class="network-select__value"
>
<UiCoin
:code="assetCode"
class="network-select__coin"
/>
<span>{{ modelValue }}</span>
</div>
<p class="network-select__label">{{ $t('network') }}</p>
<UiButton
class="network-select__action"
type="ghost"
size="small"
disabled
>
{{ actionText }}
</UiButton>
</span>
</label>
<div
v-if="invalid"
class="network-select__bottom"
>
<div class="network-select__validation-message">
{{ field.errorMessage.value }}
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, toRef, watch } from 'vue'
import { useField } from 'vee-validate'
const props = defineProps({
id: {
type: String,
required: true,
},
assetCode: {
type: String,
required: true,
},
modelValue: {
type: String,
},
disabled: {
type: Boolean,
default: false,
},
})
const id = toRef(props, 'id')
const { t } = useI18n()
const field = useField(id, 'required', {
validateOnValueUpdate: false,
syncVModel: true,
initialValue: !isEmptyValue(props.modelValue) ? props.modelValue : undefined,
})
const wrapper = ref()
const active = ref(false)
const invalid = computed(() => !props.disabled && !!field.errorMessage.value)
const actionText = computed(() =>
field.value.value ? t('change') : t('select'),
)
watch(field.value, hide)
watch(active, (value) => {
if (!value)
wrapper.value?.focus()
})
function show() {
active.value = true
}
function hide() {
active.value = false
}
function isEmptyValue(value) {
return [null, undefined, ''].includes(value)
}
</script>
<style lang="scss">
.network-select {
$self: &;
&__wrapper {
display: block;
border-radius: 12px;
outline: 1px solid $clr-grey-300;
outline-offset: -1px;
padding-inline: 16px;
background-color: $clr-white;
height: 48px;
}
&__content {
position: relative;
display: flex;
align-items: center;
height: 100%;
}
&__label {
@include txt-i-m;
position: absolute;
pointer-events: none;
top: 15px;
left: 0;
color: $clr-grey-400;
transform-origin: 0 0;
#{$self}.has-value & {
transform: translateY(-7px) scale(0.78);
color: $clr-grey-500;
}
}
&__value {
@include txt-i-m;
display: flex;
align-items: center;
gap: 4px;
padding-block: 22px 8px;
flex: 1;
}
&__coin {
--coin-size: 16px;
--coin-border-radius: 3px;
}
&__action {
margin-left: auto;
}
&__bottom {
@include txt-s-m;
margin-top: 4px;
padding-inline: 16px;
}
&__validation-message {
color: var(--input-validation-message-color);
}
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="notification-card" :class="{ 'not-read': !read }">
<div class="notification-card__icon">
<Component :is="resolveComponent(`ui-icon-${icon}`)" />
</div>
<div class="notification-card__content">
<p class="notification-card__title">
{{ title }}
</p>
<div class="notification-card__subtitle-wrapper">
<span class="notification-card__subtitle">
{{ subtitle }}
</span>
<span class="notification-card__date">
Сегодня в 14:40
</span>
</div>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import type { UiIcon } from '#build/types/ui/icons'
export interface Props {
icon: UiIcon
title: string
subtitle: string
read: boolean
}
defineProps<Props>()
</script>
<style lang="scss">
.notification-card {
position: relative;
display: grid;
grid-template-columns: 32px auto;
gap: 8px;
padding: 8px;
border-radius: 12px;
cursor: pointer;
outline: 1px solid transparent;
outline-offset: -1px;
transition: .2s ease-out;
transition-property: background-color, outline-color;
&:hover {
background-color: #F7F9FF;
}
&:active {
outline-color: $clr-cyan-300;
}
&.not-read {
&::after {
content: '';
position: absolute;
top: 12px;
right: 8px;
width: 6px;
height: 6px;
background-color: $clr-red-500;
border-radius: 50%;
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
color: var(--notification-card-icon-color, $clr-grey-500);
background-color: var(--notification-card-icon-background, $clr-grey-200);
}
&__content {
}
&__title {
@include txt-r-sb;
margin-bottom: 4px;
}
&__subtitle-wrapper {
@include txt-t-m;
display: flex;
justify-content: space-between;
}
&__subtitle {
color: $clr-grey-600;
}
&__date {
color: $clr-grey-400;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<NotificationCardBase icon="s-up-right" v-bind="props" class="card">
<p class="amount">
<span>Валюта: <strong>USDT</strong></span>
<span>Сумма: <strong>500</strong></span>
</p>
</NotificationCardBase>
</template>
<script setup lang="ts">
import type { Props } from './base.vue'
const props = defineProps<Omit<Props, 'icon'>>()
</script>
<style lang="scss" scoped>
.card {
--notification-card-icon-color: #{$clr-cyan-600};
}
.amount {
@include txt-s;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
color: $clr-grey-500;
margin-top: 8px;
background-color: $clr-grey-200;
height: 32px;
strong {
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<NotificationCardBase icon="s-down-left" v-bind="props" class="card">
<p class="amount">
<span>Валюта: <strong>USDT</strong></span>
<span>Сумма: <strong>500</strong></span>
</p>
</NotificationCardBase>
</template>
<script setup lang="ts">
import type { Props } from './base.vue'
const props = defineProps<Omit<Props, 'icon'>>()
</script>
<style lang="scss" scoped>
.card {
--notification-card-icon-color: #{$clr-green-500};
--notification-card-icon-background: #{$clr-green-100};
}
.amount {
@include txt-s;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
color: $clr-grey-500;
margin-top: 8px;
background-color: $clr-grey-200;
height: 32px;
strong {
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<NotificationCardBase icon="s-exit" v-bind="props">
<p class="location">
Местоположение: Moscow, Russia
</p>
<p class="alert">
Если это были не вы срочно
<UiButton type="link" size="small">
смените пароль
</UiButton>
или
<UiButton type="link" size="small">
свяжитесь с поддержкой
</UiButton>
</p>
</NotificationCardBase>
</template>
<script setup lang="ts">
import type { Props } from './base.vue'
const props = defineProps<Omit<Props, 'icon'>>()
</script>
<style lang="scss" scoped>
.location {
@include txt-s-m;
color: $clr-grey-400;
margin-top: 4px;
margin-bottom: 8px;
}
.alert {
@include txt-r-m;
padding: 8px;
border-radius: 6px;
background-color: $clr-grey-200;
color: $clr-grey-500;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<NotificationCardBase icon="s-clock" v-bind="props" class="card">
<div class="row">
<p class="amount">
<span>Валюта: <strong>USDT</strong></span>
<span>Сумма: <strong>500</strong></span>
</p>
<UiButton class="support" size="small" type="outlined" color="secondary">
Support
</UiButton>
</div>
</NotificationCardBase>
</template>
<script setup lang="ts">
import type { Props } from './base.vue'
const props = defineProps<Omit<Props, 'icon'>>()
</script>
<style lang="scss" scoped>
.card {
--notification-card-icon-color: #{$clr-warn-500};
--notification-card-icon-background: #{$clr-warn-200};
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.amount {
@include txt-s;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
color: $clr-grey-500;
background-color: $clr-grey-200;
height: 32px;
strong {
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<UiDropdown
class="notifications-dropdown"
dropdown-class="notifications-dropdown__content"
placement="bottom-end"
trigger="hover"
:offset="12"
teleport
>
<template #default="{ isActive }">
<NotifyButton
icon="bell"
:count="0"
class="notifications-dropdown__trigger"
:state="isActive ? 'hover' : undefined"
/>
</template>
<template #dropdown>
<div class="notifications-dropdown__header">
<h3>Notifications</h3>
<UiButton class="ml-a" size="small" icon="s-check-seen" type="ghost">
Mark all as read
</UiButton>
<UiButton class="ml-4" icon="s-kebab-android" type="link" size="small" color="secondary" />
</div>
<form class="notifications-dropdown__filter">
<UiSwitcher
id="notification_type"
:options="[
{ label: 'All', value: 'all' },
{ label: 'Поступления', value: 'p' },
{ label: 'Списания', value: 's' },
]"
size="small"
/>
</form>
<div class="notifications-dropdown__list">
<NotificationCardWithdrawCreated title="Новый вывод средств создан" subtitle="Адрес: 7u5RQ9g....bkNfG" />
<NotificationCardDeposit title="На ваш кошелек поступил новый платеж" subtitle="Адрес: 7u5RQ9g....bkNfG" />
<NotificationCardCompletedWithdraw title="Вывод средств был проведен успешно" subtitle="Адрес: 7u5RQ9g....bkNfG" />
<NotificationCardSignIn title="Вход в учетную запись" subtitle="IP: 185.218.108.156" read />
</div>
</template>
</UiDropdown>
</template>
<script setup>
import { useForm } from 'vee-validate'
useForm({
keepValuesOnUnmount: true,
initialValues: {
notification_type: 'all',
},
})
</script>
<style lang="scss">
.notifications-dropdown {
cursor: pointer;
&__header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
&__filter {
margin-bottom: 8px;
}
&__list {
//margin-inline: -16px;
//padding-inline: 16px;
//max-height: 150px;
//overflow: auto;
> *:not(:last-child) {
margin-bottom: 8px;
}
}
&__content {
position: relative;
width: 406px;
padding: 16px !important;
box-shadow: 0px 4px 4px 0px #6C86AD40;
&::before {
content: '';
width: 16px;
height: 16px;
position: absolute;
top: -8px;
right: 12px;
background-color: $clr-white;
border-radius: 2px;
transform: rotateZ(45deg);
}
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<UiButton
:class="[cn.b(), cn.has('one', count === 1), cn.has('few', count > 1)]"
:icon="icon"
type="ghost"
color="secondary"
:data-count="countContent"
/>
</template>
<script setup>
const props = defineProps({ icon: { type: String }, count: { type: Number } })
const cn = useClassname('notify-button')
const countContent = computed(() => {
return props.count > 9 ? '9+' : props.count
})
</script>
<style lang="scss">
.notify-button {
position: relative;
&::after {
@include txt-s-m;
display: block;
position: absolute;
background-color: $clr-red-500;
color: $clr-white;
}
&.has-one {
&::after {
content: '';
width: 6px;
height: 6px;
border-radius: 3px;
top: 7px;
right: 7px;
}
}
&.has-few {
&::after {
content: attr(data-count);
top: 0px;
right: 0px;
padding-inline: 4px;
border-radius: 7px;
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="operation-type">
<span class="mr-4">{{ type }}</span>
<UiIconSUpRight class="text-clr-cyan-500" />
</div>
</template>
<script setup>
defineProps({
type: {
type: String,
required: true,
},
})
</script>
<style lang="scss">
.operation-type{
@include txt-r-sb;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<form
class="page-form"
@submit="onSubmit"
>
<UiButton
icon="chevron-left"
color="secondary"
type="link"
class="mb-8"
:href="backLink"
>
{{ backText }}
</UiButton>
<slot name="title">
<h1 :class="titleOffset ? 'mb-32' : ''">
{{ title }}
</h1>
</slot>
<div class="page-form__summary">
<StaticError class="mb-16" />
<slot />
</div>
<slot name="submit" v-bind="{ isSubmitting }">
<UiButton
class="page-form__submit"
size="large"
native-type="submit"
:loading="isSubmitting"
>
{{ submitText }}
</UiButton>
</slot>
</form>
</template>
<script setup>
import { useForm } from 'vee-validate'
const props = defineProps({
title: { type: String, required: true },
backLink: { type: String, required: true },
backText: { type: String, required: true },
submitText: { type: String, required: true },
titleOffset: { type: Boolean, default: true },
handler: {
type: Function,
required: true,
},
})
const { handleSubmit, isSubmitting } = useForm()
const onSubmit = handleSubmit(async (values) => {
await props.handler(values)
})
</script>
<style lang="scss">
.page-form {
margin: 0 auto;
width: 500px;
&__summary {
padding-top: var(--page-form-padding-top, 24px);
padding-bottom: var(--page-form-padding-bottom, 16px);
}
&__submit {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="page-block">
<div class="page-block__header">
<div class="d-flex flex-column">
<div class="d-flex align-items-center" style="gap: 16px">
<h3 v-if="title">
{{ title }}
</h3>
<slot name="badge" />
</div>
<span v-if="subTitle" class="page-block__subtitle">{{ subTitle }}</span>
</div>
<slot name="actions" />
</div>
<slot />
</div>
</template>
<script setup>
defineProps({
title: { type: String },
subTitle: { type: String },
})
</script>
<style lang="scss">
.page-block {
display: flex;
flex-direction: column;
gap: var(--page-block-gap, 8px);
border-radius: 12px;
padding: var(--page-block-padding, 16px);
background-color: $clr-white;
&__header{
display: flex;
align-items: center;
justify-content: space-between;
}
&__subtitle{
@include txt-i-m;
margin-top: 4px;
color: $clr-grey-500;
}
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div class="footer-info-block">
<div class="footer-info-block__left">
<UiIconAskForDiscountFilled class="footer-info-block__icon" />
<div>
<h3 class="footer-info-block__title">
{{ title }}
</h3>
<p class="footer-info-block__text">
{{ text }}
</p>
</div>
</div>
<div class="footer-info-block__right">
<UiButton
color="secondary"
right-icon="s-chevron-right"
>
{{ action }}
</UiButton>
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
text: { type: String, required: true },
action: { type: String, required: true },
})
</script>
<style lang="scss">
.footer-info-block {
display: flex;
&__title {
color: $clr-grey-600;
margin-bottom: 8px;
}
&__text {
color: $clr-grey-500;
width: 512px;
}
&__icon {
color: $clr-market-500 !important;
margin-right: 32px;
padding: 9px;
box-sizing: content-box;
}
&__left {
display: flex;
flex: 1;
border-bottom-left-radius: 12px;
border-top-left-radius: 12px;
background-color: $clr-grey-200;
padding: 24px 20px;
}
&__right {
display: flex;
align-items: center;
justify-content: center;
border-bottom-right-radius: 12px;
border-top-right-radius: 12px;
background-color: $clr-grey-300;
width: 286px;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<header class="page-header">
<div class="page-header__title">
<UiButton
v-if="withBackButton"
icon="arrow-left"
color="secondary"
class="mr-24"
href="/projects"
type="outlined"
/>
<h1>{{ title }}</h1>
<div
v-if="$slots.default"
class="page-header__default"
>
<slot />
</div>
</div>
<div class="page-header__right">
<slot name="right">
<div class="d-flex align-items-center" style="gap: 24px;">
<NotificationsDropdown />
<ProfileDropdown />
</div>
</slot>
</div>
</header>
</template>
<script setup>
defineProps({
title: {
type: String,
},
withBackButton: {
type: Boolean,
default: false,
},
})
const notify = useNotify()
</script>
<style lang="scss">
@use 'sass:color';
.page-header {
position: sticky;
backdrop-filter: blur(4px);
background-size: 4px 4px;
background-image: radial-gradient(
color.change($clr-grey-100, $alpha: 0.7) 2px,
$clr-cyan-200
);
border-bottom: 1px solid #f4f6ff;
margin-inline: -16px;
padding-inline: 16px;
top: 0;
z-index: 6000;
display: flex;
align-items: center;
justify-content: space-between;
//margin-bottom: 32px;
padding-block: 32px;
&__title {
display: flex;
align-items: center;
}
&__default {
display: flex;
align-items: center;
gap: 24px;
margin-left: 24px;
}
&__right {
display: flex;
align-items: center;
gap: 24px;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<div class="info-block">
<div class="d-flex justify-content-between mb-16">
<p class="info-block__title">
{{ title }}
</p>
<UiBadge v-if="badge" type="marketing">
{{ badge }}
</UiBadge>
</div>
<slot name="info" />
<span class="info-block__text">{{ text }}</span>
<UiButton
class="info-block__action"
type="outlined"
:href="link"
right-icon="s-chevron-right"
size="small"
>
{{ action }}
</UiButton>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
text: {
type: String,
required: true,
},
badge: {
type: String,
},
action: {
type: String,
required: true,
},
link: {
type: String,
},
})
</script>
<style lang="scss">
.info-block {
display: flex;
flex-direction: column;
position: relative;
width: 270px;
min-height: 179px;
border-radius: 12px;
padding: 16px;
background-color: $clr-grey-100;
&__title {
@include txt-m-sb;
}
&__text {
@include txt-s;
margin-bottom: 16px;
color: $clr-grey-500;
}
&__action {
margin-top: auto;
align-self: flex-start;
}
}
.info-block-addInfo {
@include txt-s;
margin-bottom: 16px;
&__title {
margin-bottom: 4px;
color: $clr-grey-500;
}
&__sum {
@include txt-m-sb;
}
&__text {
@include txt-r;
color: $clr-grey-400;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div class="page-toolbar">
<slot name="prefix" />
<UiSearch
class="flex-1"
size="large"
:label="searchLabel"
:model-value="search"
@update:model-value="$emit('update:search', $event)"
/>
<slot />
</div>
</template>
<script setup>
defineProps({
searchLabel: { type: String, required: true },
search: { type: String },
})
defineEmits(['update:search'])
</script>
<style lang="scss">
.page-toolbar {
display: flex;
gap: 16px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<UiDropdown
v-if="authenticated"
class="profile-dropdown"
dropdown-class="profile-dropdown__content"
placement="bottom-end"
trigger="hover"
:offset="12"
teleport
>
<template #default="{ isActive }">
<UiButton class="profile-dropdown__trigger" icon="circle-userpic" :state="isActive ? 'hover' : undefined" />
</template>
<template #dropdown>
<UiDropdownItem class="mb-12" @click="navigateTo('/2fa')">
<template #icon>
<UiIconSProtection class="text-clr-cyan-400" />
</template>
<span>Безопасность</span>
</UiDropdownItem>
<UiDropdownItem class="mb-12" @click="navigateTo('/verification/status')">
<template #icon>
<UiIconSOverview class="text-clr-cyan-400" />
</template>
<span>Лимиты</span>
</UiDropdownItem>
<UiDropdownItem @click="navigateTo('/settings')">
<template #icon>
<UiIconSSettings class="text-clr-cyan-400" />
</template>
<span>Настройки</span>
</UiDropdownItem>
<UiDropdownSeparator />
<UiDropdownItem @click="logout">
<template #icon>
<UiIconSExit class="text-clr-grey-400" />
</template>
<span>Logout</span>
</UiDropdownItem>
</template>
</UiDropdown>
</template>
<script setup>
const { logout, authenticated } = useAuth()
</script>
<style lang="scss">
.profile-dropdown {
cursor: pointer;
&__trigger {
--button-color: #{$clr-grey-500};
--button-background: #{$clr-grey-200};
--button-hover-color: #{$clr-white};
--button-active-color: #{$clr-white};
}
&__content {
@include txt-s-m('dropdown-item', true);
--dropdown-item-padding: 8px 12px;
position: relative;
color: $clr-grey-600;
width: 233px;
padding: 16px !important;
box-shadow: 0px 4px 4px 0px #6C86AD40;
&::before {
content: '';
width: 16px;
height: 16px;
transform: rotateZ(45deg);
position: absolute;
top: -8px;
right: 12px;
background-color: $clr-white;
border-radius: 2px;
}
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="project-create">
<img src="/flag.svg" class="mb-40">
<i18n-t scope="global" keypath="create_your_project.title" tag="h2">
<span class="text-clr-cyan-500">
{{ $t('create_your_project.create') }}
</span>
</i18n-t>
<h4 class="project-create__text">
{{ $t('create_your_project.content') }}
</h4>
<UiButton size="large" class="mt-40 w-100" href="/projects/create">
{{ $t('create') }}
</UiButton>
</div>
</template>
<style lang="scss">
.project-create {
display: flex;
align-items: center;
flex-direction: column;
width: 422px;
align-self: center;
justify-self: center;
&__text {
color: $clr-grey-400;
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="info-columns">
<PageInfoBlock
title="More limits"
text="Get increased limits and advanced features by providing a little more profile information"
action="Increase the limit"
link="/verification/status"
badge="BASIC"
style="
background-image: url('/block2.jpg');
background-size: cover;
background-position: bottom right;
"
>
<template #info>
<div
v-if="!maxState"
class="limits"
>
<p class="limits__date">
Updated on <strong>{{ dayjs().format('DD.MM.YYYY') }}</strong>
</p>
<div>
<p class="limits__title">
Remaining limit
</p>
<div class="mb-4 d-flex justify-content-between">
<span class="limits__current">{{ dayLimit }} <span class="limits__current-text">/ per day</span></span>
<span class="limits__total">{{ curDayLimit }} USDT</span>
</div>
<UiProgressBar :progress="(curDayLimit / dayLimit) * 100" />
<div class="mb-4 mt-24 d-flex justify-content-between">
<span class="limits__current">{{ monthLimit }} <span class="limits__current-text">/ per month</span></span>
<span class="limits__total">{{ curMonthLimit }} USDT</span>
</div>
<UiProgressBar :progress="(curMonthLimit / monthLimit) * 100" />
</div>
</div>
</template>
</PageInfoBlock>
<PageInfoBlock
title="2FA authentication"
text="Complete the verification process to remove withdrawal limits."
action="Add 2FA"
link="/2fa"
/>
</div>
</template>
<script setup>
import dayjs from 'dayjs'
const maxState = ref(false)
const curDayLimit = ref(100)
const curMonthLimit = ref(5200)
const dayLimit = ref(1000)
const monthLimit = ref(10000)
</script>
<style lang="scss">
.info-columns {
> *:not(:last-child) {
margin-bottom: 8px;
}
}
.limits {
margin-bottom: 24px;
&__date {
@include txt-s;
color: $clr-grey-500;
margin-bottom: 16px;
}
&__title {
@include txt-s-m;
color: $clr-grey-400;
text-align: right;
}
&__total {
@include txt-s-m;
color: $clr-grey-500;
}
&__current {
@include txt-m-sb;
}
&__current-text{
@include txt-r-sb;
color: $clr-grey-400;
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<UiPlainTable class="projects-table" :columns="columns" :data="projects">
<template #cell(name)="{ row }">
<div class="name-cell">
<div class="name-cell__initials">
{{ getProjectInitials(row.original) }}
</div>
<div class="name-cell__name">
{{ row.original.name }}
</div>
<div class="name-cell__id">
id {{ row.original.id }}
</div>
</div>
</template>
<template #cell(balance)="{ row }">
<MoneyAmount :value="row.original.account.balance" currency="USDT" />
</template>
<template #cell(withdraw)="{ row }">
<MoneyAmount value="0" currency="USDT" />
</template>
<template #cell(actions)="{ row }">
<UiButton class="mr-16" color="secondary">
API
</UiButton>
<UiButton class="mr-16" color="secondary">
Settings
</UiButton>
<UiButton :href="`/projects/${row.original.id}`">
Open
</UiButton>
</template>
</UiPlainTable>
</template>
<script setup lang="ts">
import { createColumnHelper } from '@tanstack/vue-table'
interface ProjectListItem {
id: number
name: string
}
defineProps({
projects: {
type: Array,
required: true,
},
})
const columnHelper = createColumnHelper<ProjectListItem>()
const columns = [
columnHelper.accessor('name', {
header: 'Name',
}),
columnHelper.display({
id: 'balance',
header: 'Balance',
}),
columnHelper.display({
id: 'withdraw',
header: 'In sending',
}),
columnHelper.display({
id: 'actions',
}),
]
function getProjectInitials(project: ProjectListItem) {
return project.name.slice(0, 1)
}
</script>
<style lang="scss">
.projects-table {
@include txt-i-sb('money-amount-value', true);
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
text-align: left;
margin-block: -8px;
td,
th {
margin: 0;
padding-inline: 16px;
background-color: $clr-grey-100;
&:first-child {
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
}
&:last-child {
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
}
}
th {
@include txt-i-m;
color: $clr-grey-500;
padding-block: 16px;
&.name {
padding-left: 64px;
}
}
td {
padding-block: 24px;
height: 95.8px;
&.actions {
background-color: $clr-grey-200;
padding-left: 32px;
width: 1%;
white-space: nowrap;
}
}
}
.name-cell {
display: grid;
align-items: center;
grid-template-columns: 32px auto;
gap: 4px 16px;
&__initials {
@include font(20px, 500, 32px);
background-color: $clr-grey-400;
height: 32px;
text-align: center;
border-radius: 6px;
color: $clr-white;
text-transform: uppercase;
grid-column: 1;
grid-row: span 2;
}
&__name {
@include txt-i-sb;
grid-column: 2;
color: $clr-black;
}
&__id {
@include txt-s-m;
grid-column: 2;
color: $clr-grey-500;
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div
:class="[
cn.b(),
cn.is('checked', checked),
cn.is('disabled', disabled),
cn.is('focused', focused),
cn.is('disabled', disabled),
]"
@click="handleChange"
>
<p :class="[cn.e('label')]">
<slot>
{{ label }}
</slot>
</p>
<p v-if="caption || $slots.caption" :class="[cn.e('caption')]">
<slot name="caption">
{{ caption }}
</slot>
</p>
</div>
</template>
<script setup lang="ts">
export interface Props {
id: string
value: string | number
label?: string
caption?: string
disabled?: boolean
modelValue?: string | number
required?: boolean
}
defineOptions({
name: 'RadioButton',
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
trueValue: true,
falseValue: false,
required: false,
modelValue: undefined,
})
const slots = useSlots()
const { checked, focused, handleChange } = useRadio(props, slots)
const cn = useClassname('radio-button')
</script>
<style lang="scss">
.radio-button {
$self: &;
@include txt-i-m;
--border-color: #{$clr-grey-300};
--border-width: 1px;
display: inline-flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: center;
height: 48px;
padding: 8px 16px;
border-radius: 12px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
cursor: pointer;
transition: .2s ease-out;
transition-property: outline-color, background-color, color;
user-select: none;
&:hover {
--border-color: transparent;
background-color: $clr-grey-200;
}
&.is-checked {
--border-color: transparent;
background-color: $clr-cyan-500;
color: $clr-white;
}
&.is-disabled {
opacity: 0.4;
pointer-events: none;
}
&__caption {
@include txt-r-m;
color: $clr-cyan-400;
#{$self}.is-checked & {
color: $clr-cyan-300;
}
}
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<UiDropdown
class="resource-filter"
position="bottom-start"
:offset="0"
dropdown-class="resource-filter__dropdown"
transition-name="ui-select"
>
<template #default>
<div class="resource-filter__wrapper">
<p class="resource-filter__label">
{{ label }}
</p>
<div class="resource-filter__content">
<p class="resource-filter__value">
{{ value }}
</p>
<i
v-if="filled"
class="resource-filter__clear icon-s-cross-compact"
tabindex="0"
@click.stop="$emit('clear')"
/>
</div>
<UiIconChevronDown class="resource-filter__chevron" />
</div>
</template>
<template #dropdown>
<slot />
</template>
</UiDropdown>
</template>
<script setup lang="ts">
export interface Props {
label: string
value: string
filled?: boolean
}
withDefaults(defineProps<Props>(), {
filled: false,
})
defineEmits(['clear'])
</script>
<style lang="scss">
.resource-filter {
$self: &;
&__wrapper {
display: grid;
grid-template-areas: 'label chevron' 'content chevron';
grid-template-columns: 1fr auto;
column-gap: 8px;
align-items: center;
padding: 8px 16px;
border-radius: 12px;
cursor: pointer;
background-color: var(--select-background);
transition: 0.2s ease-out;
transition-property: background-color, border-radius, box-shadow;
outline: none;
width: 169px;
&:focus-visible,
&:hover {
background-color: var(--select-hover-background);
}
&:active {
background-color: var(--select-active-background);
}
#{$self}.is-active & {
border-radius: 12px 12px 0 0;
box-shadow: 0 4px 4px 0 #6c86ad40;
}
}
&__label {
@include txt-s-m;
grid-area: label;
color: var(--select-label-color);
transition: color 0.2s ease-out;
user-select: none;
#{$self}.is-active &,
#{$self}__wrapper:hover &,
#{$self}__wrapper:focus-visible & {
color: var(--select-label-hover-color);
}
}
&__content {
display: flex;
grid-area: content;
}
&__value {
@include txt-i-m;
background: none;
color: var(--select-color);
padding: 0;
border: none;
width: 100%;
outline: none;
cursor: pointer;
user-select: none;
pointer-events: none;
appearance: none;
white-space: nowrap;
&::placeholder {
color: var(--select-color);
}
}
&__clear {
color: var(--select-clear-color);
transition: color 0.2s ease-out;
&:hover {
color: var(--select-clear-hover-color);
}
}
&__chevron {
color: var(--select-chevron-color);
grid-area: chevron;
}
&__dropdown {
border-radius: 0 0 12px 12px;
box-shadow: 0 4px 4px 0 #6c86ad40;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<ResourceFilterBase
:label="label"
:value="value"
:filled="modelValue && modelValue.length > 0"
@clear="$emit('update:modelValue', [])"
>
<UiCalendar
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
/>
</ResourceFilterBase>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
export interface Props {
label: string
placeholder: string
modelValue?: [from?: number, to?: number]
}
defineOptions({
name: 'ResourceFilterCalendar',
})
const props = defineProps<Props>()
defineEmits(['update:modelValue'])
const value = computed(() => {
if (!props.modelValue)
return props.placeholder
const [start, end] = props.modelValue
if (!start)
return props.placeholder
if (start && end) {
const from = dayjs(start).format('DD.MM')
const to = dayjs(end).format('DD.MM.YY')
return `${from}${to}`
}
else if (start) {
return dayjs(start).format('DD.MM.YY')
}
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<UiSelect
class="resource-filter resource-filter--select"
v-bind="props"
:model-value="modelValue"
clearable
emit-value
map-options
@update:model-value="$emit('update:modelValue', $event)"
/>
</template>
<script setup lang="ts">
import type { Props } from 'ui-layer/components/select/types'
defineOptions({
name: 'ResourceFilterSelect',
})
const props = defineProps<Props>()
defineEmits(['update:modelValue'])
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div class="resource-filters">
<div class="resource-filters__filters">
<Component
:is="getFilterComponent(filter)"
v-for="filter in schema as Filters"
:id="`resource_filter.${filter.key}`"
v-bind="filter"
:key="filter.key"
:model-value="appliedFiltersRaw[filter.key]"
@update:model-value="apply(filter.key, $event)"
/>
</div>
<UiButton
v-if="!empty"
type="outlined"
icon="s-cross"
class="resource-filters__clear"
@click="reset"
>
Clear filters
</UiButton>
</div>
</template>
<script setup lang="ts">
import type { Filter, Filters } from '#imports'
const { schema, appliedFiltersRaw, empty, apply, reset } = inject(
filtersContextKey,
{
schema: [],
appliedFiltersRaw: {},
appliedFilters: computed(() => ({})),
empty: true,
apply: () => {},
reset: () => {},
},
)
function getFilterComponent(filter: Filter) {
switch (filter.type) {
case 'calendar':
return resolveComponent('ResourceFilterCalendar')
case 'select':
default:
return resolveComponent('ResourceFilterSelect')
}
}
</script>
<style lang="scss">
.resource-filters {
display: flex;
align-items: center;
gap: 16px;
&__filters {
display: flex;
gap: 8px;
}
&__clear {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<SettingsRawTable :columns="columns" :data="data">
<template #cell(finance_notifications)="{ row }">
<div class="d-flex align-items-center text-clr-grey-600">
<Component :is="resolveComponent(`ui-icon-${row.original.icon}`)" :style="`color: ${row.original.color}`" />
<h4 class="ml-10">
{{ row.original.finance_notifications }}
</h4>
</div>
</template>
<template #cell(telegram)="{ row }">
<div class="d-flex justify-content-center">
<UiCheckbox id="telegram" />
</div>
</template>
<template #cell(mail)="{ row }">
<div class="d-flex justify-content-center">
<UiCheckbox id="mail" :model-value="true" disabled />
</div>
</template>
<template #cell(push)="{ row }">
<div class="d-flex justify-content-center">
<UiCheckbox id="push" :model-value="true" />
</div>
</template>
</SettingsRawTable>
</template>
<script setup>
import { createColumnHelper } from '@tanstack/vue-table'
const columnHelper = createColumnHelper()
const columns = [
columnHelper.display({
id: 'finance_notifications',
header: 'Finance Notifications',
}),
columnHelper.display({
id: 'telegram',
header: 'Telegram',
}),
columnHelper.display({
id: 'mail',
header: 'Mail',
}),
columnHelper.display({
id: 'push',
header: 'Push',
}),
]
const data = [
{ finance_notifications: 'Поступления средств', icon: 'ArrowReceive', color: '#10C44C' },
{ finance_notifications: 'Выводы', icon: 'ArrowSend', color: '#1464D3' },
{ finance_notifications: 'Счет частично оплачен', icon: 'InstallmentPlan' },
]
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="block">
<span class="text-small">Spent</span>
<span class="text-small">Remaining limit </span>
</div>
<UiProgressBar class="progress" :progress="(amount / maxAmount) * 100" />
<div class="block">
<span class="text-amount">{{ amount }} <span class="text-currency">{{ currency }}</span></span>
<span class="text-amount">{{ maxAmount - amount }} <span class="text-currency">{{ currency }}</span> </span>
</div>
</template>
<script setup>
defineProps({
currency: { type: String, required: true },
amount: { type: Number, required: true },
maxAmount: { type: Number, required: true },
})
</script>
<style lang="scss" scoped>
.block{
display: flex;
justify-content: space-between;
}
.progress{
margin-block: 8px;
}
.text-small {
@include txt-r;
color: $clr-grey-500;
}
.text-currency {
@include txt-i-sb;
color: $clr-grey-400;
}
.text-amount{
@include txt-i-sb;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="settings-property-item">
<Component
:is="resolveComponent(`ui-icon-${icon}`)"
class="settings-property-item__icon"
/>
<div class="settings-property-item-content">
<p class="settings-property-item-content__title">
{{ title }}
</p>
<p v-if="text" class="settings-property-item-content__text">
{{ text }}
</p>
</div>
</div>
</template>
<script setup>
defineProps({
icon: { type: String, required: true },
title: { type: String, required: true },
text: { type: String },
})
</script>
<style lang="scss">
.settings-property-item {
display: flex;
align-items: center;
&__icon{
color: $clr-cyan-500;
margin-right: 10px;
}
}
.settings-property-item-content{
&__title {
@include txt-i-sb;
}
&__text {
@include txt-s-m;
margin-top: 4px;
color: $clr-grey-500;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div class="settings-property">
<SettingsPropertyItem :icon="icon" :text="text" :title="title" />
<slot />
</div>
</template>
<script setup>
defineProps({
icon: { type: String, required: true },
title: { type: String, required: true },
text: { type: String },
})
</script>
<style lang="scss">
.settings-property {
display: flex;
align-items: center;
justify-content: space-between;
height: 62px;
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<table class="settings-table">
<thead>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:class="[header.id]"
:style="{
width: `${header.getSize()}px`,
}"
>
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</template>
</th>
</tr>
</thead>
<slot>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[cell.column.id]"
>
<slot :name="`cell(${cell.column.id})`" v-bind="cell.getContext()">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</slot>
</td>
</tr>
</tbody>
</slot>
</table>
</template>
<script setup lang="ts">
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
import type { ColumnDef } from '@tanstack/vue-table'
export interface Props {
columns: ColumnDef<unknown>[]
data: unknown[]
}
defineOptions({
name: 'SettingsTable',
})
const props = defineProps<Props>()
const table = useVueTable({
get data() {
return props.data
},
get columns() {
return props.columns
},
getCoreRowModel: getCoreRowModel(),
})
</script>
<style lang="scss">
.settings-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-block: -8px;
th {
@include h5;
background-color: $clr-grey-100;
color: $clr-grey-500;
&:first-child {
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
}
&:last-child {
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
}
}
td{
}
td,
th {
padding: 16px 24px;
&:first-child {
width: 300px;
text-align: left;
}
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<SettingsRawTable :columns="columns" :data="data">
<template #cell(service_notifications)="{ row }">
<div class="d-flex align-items-center text-clr-grey-600">
<Component :is="resolveComponent(`ui-icon-${row.original.icon}`)" />
<h4 class="ml-10">
{{ row.original.service_notifications }}
</h4>
</div>
</template>
<template #cell(telegram)="{ row }">
<div class="d-flex justify-content-center">
<UiCheckbox id="telegram" />
</div>
</template>
<template #cell(mail)="{ row }">
<div class="d-flex justify-content-center">
<UiCheckbox id="mail" :model-value="true" />
</div>
</template>
<template #cell(push)="{ row }">
<div class="d-flex justify-content-center">
<UiCheckbox id="push" :model-value="true" />
</div>
</template>
</SettingsRawTable>
</template>
<script setup>
import { createColumnHelper } from '@tanstack/vue-table'
const columnHelper = createColumnHelper()
const columns = [
columnHelper.display({
id: 'service_notifications',
header: 'Service Notifications',
}),
columnHelper.display({
id: 'telegram',
header: 'Telegram',
}),
columnHelper.display({
id: 'mail',
header: 'Mail',
}),
columnHelper.display({
id: 'push',
header: 'Push',
}),
]
const data = [
{ service_notifications: 'Вход в аккаунт', icon: 'signin' },
]
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="settings-tariff-card">
<div class="settings-tariff-card__header">
<div>
<slot name="icon" />
<h4 class="d-inline-block ml-8 text-clr-grey-600">
{{ title }}
</h4>
</div>
<slot name="subtitle" />
</div>
<div class="settings-tariff-card__content">
<slot name="content" />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
})
</script>
<style lang="scss">
.settings-tariff-card {
&__header{
padding: 24px;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background-color: $clr-grey-100;
display: flex;
justify-content: space-between;
align-items: center;
}
&__content{
padding: 24px;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
border: 2px solid $clr-grey-100;
display: flex;
gap: 32px;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<VueFinalModal
:modal-id="modalId"
class="sidebar"
content-class="sidebar__content"
content-transition="sidebar"
hide-overlay
background="interactive"
:click-to-close="false"
:z-index-fn="({ index }) => 6000 + 2 * index"
>
<div class="sidebar__top">
<UiButton icon="arrow-left" type="outlined" color="secondary" @click="vfm.close(modalId)" />
<slot name="top" />
</div>
<div v-if="$slots.default" class="sidebar__middle">
<slot />
</div>
<div v-if="$slots.bottom" class="sidebar__bottom">
<slot name="bottom" />
</div>
</VueFinalModal>
</template>
<script setup>
import { VueFinalModal, useVfm } from 'vue-final-modal'
const props = defineProps({
id: {
type: String,
},
})
const vfm = useVfm()
const modalId = computed(() => {
if (props.id)
return `sidebar-${props.id}`
return 'sidebar'
})
</script>
<style lang="scss">
.sidebar {
&__content {
width: 353px;
position: absolute;
right: 0;
display: flex;
flex-direction: column;
background-color: $clr-white;
height: 100%;
gap: 32px;
padding: 16px;
}
&__top {
display: flex;
align-items: center;
gap: 16px;
}
&__middle {
flex: 1;
overflow-y: auto;
}
&-enter-active,
&-leave-active {
transition: transform .2s ease-in-out;
}
&-enter-to,
&-leave-from {
//transform: rotateZ(360deg);
}
&-enter-from,
&-leave-to {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<Transition name="fade">
<UiAlert v-if="staticError" type="negative" :text="staticError.message" />
</Transition>
</template>
<script setup lang="ts">
const staticError = useStaticError()
</script>

View File

@@ -0,0 +1,139 @@
<template>
<div class="form-stepper">
<template
v-for="(label, index) in items"
:key="index"
>
<div
class=" form-stepper__step"
:class="{
'form-stepper__step--current': index === step,
'form-stepper__step--passed': index < step,
}"
>
<div class="form-stepper__index">
<span v-if="index > step - 1 ">
{{ index + 1 }}
</span>
<UiIconCheck
v-else
class="form-stepper__icon"
/>
</div>
<div
class="form-stepper__label"
v-html="label"
/>
</div>
<div
v-if="index !== items.length - 1"
class="form-stepper__line"
:class="{
'form-stepper__line--passed': index > step - 1,
}"
/>
</template>
</div>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true,
},
step: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['set'])
</script>
<style lang="scss">
.form-stepper {
$self: &;
width: 100%;
display: flex;
align-items: center;
&__line {
$line: &;
height: 3px;
flex: 1;
background-image: linear-gradient(90deg, #a5e9bc 0%, #a7b9d5 100%);
margin-inline: 4px;
border-radius: 2px;
&--passed {
background-image: linear-gradient(90deg, #a7b9d5 0%, #dfe5ff 100%);
}
}
&__step {
display: flex;
align-items: center;
flex-direction: column;
position: relative;
user-select: none;
&:first-child {
justify-self: flex-start;
}
&:last-child {
justify-self: flex-end;
}
&--passed {
--form-stepper-index-background: #{$clr-green-500};
--form-stepper-label-color: #{$clr-green-500};
//color: var(--form-stepper-index-color, #{$clr-white});
cursor: initial;
}
&--current {
--form-stepper-index-background: #{$clr-grey-300};
--form-stepper-label-color: #{$clr-grey-600};
--form-stepper-index-color: #{$clr-grey-600};
cursor: initial;
}
}
&__icon {
--icon-size: 20px;
line-height: 18px;
color: $clr-white;
}
&__index {
@include txt-i-m;
width: 32px;
height: 32px;
line-height: 32px;
border-radius: 8px;
text-align: center;
background: var(--form-stepper-index-background, #{$clr-grey-200});
color: var(--form-stepper-index-color, #{$clr-grey-400});
cursor: pointer;
}
&__label {
@include txt-r;
color: var(--form-stepper-label-color, #{$clr-grey-600});
margin-top: 16px;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<UiPlainTable
class="stripped-table"
:class="{ 'is-loading': loading }"
:columns="columns"
:data="data"
>
<template v-if="loading" #default>
<tbody>
<tr v-for="i in 6" :key="i">
<td :colspan="columns.length">
<div class="stripped-table__skeleton">
<div
v-for="j in i % 2 === 0 ? 7 : 3"
:key="j"
class="stripped-table__skeleton-cell"
/>
</div>
</td>
</tr>
</tbody>
</template>
<template v-else-if="!data.length" #default>
<tbody>
<tr>
<td class="stripped-table__no-data" :colspan="columns.length">
<img src="/no-data.svg" alt="No Data">
<h4>No Data</h4>
</td>
</tr>
</tbody>
</template>
<template v-for="(_, slot) of $slots" #[slot]="scope">
<slot :name="slot" v-bind="scope" />
</template>
</UiPlainTable>
</template>
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
export interface Props {
columns: ColumnDef<unknown>[]
data: unknown[]
loading?: boolean
}
withDefaults(defineProps<Props>(), {
loading: false,
})
</script>
<style lang="scss">
.stripped-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 4px;
text-align: left;
margin-block: -4px;
td,
th {
margin: 0;
padding: 16px 8px;
&:first-child {
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
padding-left: 24px;
}
&:last-child {
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
padding-right: 24px;
}
}
th {
@include h5;
background-color: $clr-grey-100;
color: $clr-grey-500;
}
&.is-loading {
td {
background-color: $clr-grey-100;
}
}
&:not(.is-loading) {
tbody {
tr:nth-child(even) {
td {
background-color: $clr-grey-100;
}
}
}
}
&__no-data {
height: 494px;
text-align: center;
h4 {
margin-top: 8px;
color: $clr-grey-400;
}
}
&__skeleton {
display: flex;
align-items: center;
gap: 16px;
}
&__skeleton-cell {
border-radius: 12px;
background-color: $clr-white;
background-image: linear-gradient(
110deg,
$clr-white 8%,
$clr-grey-100 18%,
$clr-white 33%
);
background-size: 200% 100%;
height: 36px;
animation: 1.5s shine linear infinite;
width: 100%;
}
}
@keyframes shine {
to {
background-position-x: -200%;
}
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<div
class="verification-card"
:class="{
'verification-card--passed': passed,
}"
>
<div class="verification-card__header">
<UiBadge
v-if="badge"
:type="badge.type"
class="mb-24"
>
{{ badge.title }}
</UiBadge>
<p class="verification-card-info__title">
Withdrawal
</p>
<div
v-if="info"
class="verification-card-info"
>
<div>
<span class="verification-card-info__per-month-amount">
{{ info.perMonth }}
</span>
<span class="verification-card-info__per-month-text"> / month </span>
</div>
<div class="mt-4">
<span class="verification-card-info__per-day-amount">
{{ info.perDay }}
</span>
<span class="verification-card-info__per-day-text"> / day </span>
</div>
</div>
<div
v-else
class="verification-card-info"
>
<span class="verification-card-info__per-month-amount">
No limit
</span>
<span class="verification-card-info__per-month-text"> / month & day </span>
</div>
</div>
<div class="verification-card-body">
<slot />
</div>
<div class="verification-card__action">
<UiAlert
v-if="passed"
type="positive"
text="Your current status"
/>
<UiButton
v-else
class="w-100"
size="large"
:href="link"
>
Start verification
</UiButton>
</div>
</div>
</template>
<script setup>
defineProps({
passed: {
type: Boolean,
default: false,
},
badge: {
type: Object,
required: true,
},
info: {
type: Object,
default: null,
},
link: {
type: String,
default: '#',
},
})
</script>
<style lang="scss">
.verification-card {
$self: &;
display: flex;
flex-direction: column;
width: var(--verification-card-width, 405px);
//height: 585px;
padding: 24px;
border-radius: 12px;
background-color: $clr-grey-200;
&__header {
height: 157px;
border-bottom: 1px solid $clr-grey-400;
margin-bottom: 16px;
}
&__action {
margin-top: auto;
}
&--passed {
background-color: $clr-white;
outline: $clr-green-500 solid 2px;
outline-offset: -2px;
.verification-card__title {
color: $clr-black;
}
}
}
.verification-card-info {
&__per-month-amount {
@include h2;
}
&__per-month-text {
@include txt-l-sb;
color: $clr-grey-400;
}
&__per-day-amount {
@include txt-l-sb;
}
&__per-day-text {
@include txt-l-sb;
color: $clr-grey-400;
}
&__title {
@include txt-l-m;
color: $clr-grey-500;
margin-bottom: 4px;
}
}
.verification-card-body {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div>
<div
v-for="block in propertyBlocks"
:key="block.title"
class="elements"
>
<span class="title">
{{ block.title }}
</span>
<h5 class="text-clr-grey-500">
{{ block.underTitle }}
</h5>
<VerificationProperty
v-for="property in block.properties"
:key="property.title"
:title="property.title"
:text="property.text"
/>
</div>
</div>
</template>
<script setup>
const propertyBlocks=[
{
title: 'Restrictions',
underTitle: 'Invoices',
properties:[
{title:'Minimum amount in the invoice is', text:' 5 USDT'},
{title:'Commission for creating an invoice is', text:'0.5 USDT'},
{title:'Limit for creating invoices is', text:'200 per day, 1000 per month'},
]},
{
underTitle: 'Withdrawal of funds',
properties:[
{title:'Minimum withdrawal amount is', text:'5 USDT'},
{title:'Commission for withdrawal is', text:'2.5 USDT + 1%'}
]},
]
</script>
<style lang="scss" scoped>
.elements {
display: flex;
flex-direction: column;
gap: 16px;
}
.title{
@include txt-m-b;
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<div>
<div class="elements mb-16">
<span class="requirements"> Requirements </span>
<div>
<UiIconSSocialCard class="mr-4 text-clr-grey-400 d-inline-block" />
<span class="text"> Organization data </span>
</div>
<div>
<UiIconSExcursion class="mr-4 text-clr-grey-400" />
<span class="text">Identity verification </span>
</div>
</div>
<div
v-for="block in propertyBlocks"
:key="block.title"
class="elements"
>
<span class="title">
{{ block.title }}
</span>
<h5 class="text-clr-grey-500">
{{ block.underTitle }}
</h5>
<VerificationProperty
v-for="property in block.properties"
:key="property.title"
:title="property.title"
:text="property.text"
/>
</div>
</div>
</template>
<script setup>
const propertyBlocks = [
{
title: 'Restrictions',
underTitle: 'Invoices',
properties: [
{ title: 'Invoice creation', text: 'unlimited' },
{ title: 'Commission for creating an invoice is', text: '0.5 USDT' },
],
},
{
underTitle: 'Withdrawal of funds',
properties: [
{ title: 'Commission for withdrawal is', text: '2.5 USDT + 1%' },
],
},
]
</script>
<style lang="scss" scoped>
.elements {
display: flex;
flex-direction: column;
gap: 16px;
}
.title{
@include txt-m-b;
}
.text{
@include txt-r-m;
color: $clr-grey-500;
}
.requirements{
@include txt-m-b;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="varification-property">
<Component
:is="resolveComponent(`ui-icon-${icon}`)"
class="mr-4"
:class="icon === 'SCheck' ? 'text-clr-green-500' : ''"
/>
<span class="varification-property__title">
{{ title }}
<span class="varification-property__text">
{{ text }}
</span>
</span>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
text: {
type: String,
default: '',
},
icon: {
type: String,
default: 'SCheck',
},
})
</script>
<style lang="scss">
.varification-property {
color: $clr-grey-500;
&__title {
@include txt-r;
}
&__text {
@include txt-r-b;
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div>
<p class="mb-16">
<strong> We accept: </strong>
</p>
<ul class="accept-list">
<li
v-for="listItem in list"
:key="listItem.title"
>
<span> {{ listItem.title }} </span>
<p class="text-clr-grey-500">
{{ listItem.underTitle }}
</p>
</li>
</ul>
</div>
</template>
<script setup>
defineProps({
list: {
type: Array,
required: true
}
})
</script>
<style lang="scss">
.accept-list{
list-style-type: disc;
padding: 0 0 0 24px;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
</style>

View File

@@ -0,0 +1,39 @@
import type { NitroFetchRequest } from 'nitropack'
import { callWithNuxt } from '#app'
export function $api<
T = unknown,
R extends NitroFetchRequest = NitroFetchRequest,
>(
request: Parameters<typeof $fetch<T, R>>[0],
options?: Partial<Parameters<typeof $fetch<T, R>>[1]>,
) {
const nuxtApp = useNuxtApp()
const runtimeConfig = useRuntimeConfig()
const cookies = useRequestHeaders(['cookie'])
return $fetch<T, R>(request, {
...options,
headers: {
...options?.headers,
...cookies,
},
retry: false,
baseURL: runtimeConfig.public.apiHost as string,
credentials: 'include',
onResponseError: async ({ response }) => {
if (response.status === 401) {
nuxtApp.runWithContext(() => {
useCookie('session').value = null
})
await callWithNuxt(nuxtApp, clearNuxtState, ['user'])
await callWithNuxt(nuxtApp, navigateTo, ['/login', { redirectCode: 401 }])
}
// setStaticError({
// status: response.status,
// message: nuxtApp.$i18n.t('something_went_wrong'),
// });
},
})
}

View File

@@ -0,0 +1,16 @@
export interface StaticError {
status: number
message?: string
}
export function useStaticError() {
return useState<StaticError | undefined>('static-error')
}
export function setStaticError(value?: StaticError) {
useStaticError().value = value
}
export function clearStaticError() {
clearNuxtState(['static-error'])
}

View File

@@ -0,0 +1,69 @@
export interface User {
id: string
email: string
}
export default () => {
const nuxtApp = useNuxtApp()
const user = useState<User | null>('user')
const authenticated = computed(() => !!user.value)
async function getUser() {
user.value = await $api('/users/current', { method: 'GET' })
}
async function login(email: string, password: string) {
await $api('/sessions', { method: 'POST', body: { email, password } })
await getUser()
navigateTo('/projects')
}
async function register(email: string, password: string) {
await $api('/users', { method: 'POST', body: { email, password } })
navigateTo('/login')
}
async function logout() {
try {
await $api('/sessions', { method: 'delete', body: {} })
}
finally {
clearNuxtState('user')
navigateTo('/login')
}
}
async function requestResetPassword(email: string) {
await $api('/users/password_reset', { method: 'post', body: { email } })
}
async function resetPassword(newPassword: string, resetCode: string) {
await $api('/users/password_reset', {
method: 'put',
body: {
newPassword,
resetCode,
},
})
}
async function resendVerificationCode(email: string) {
await $api('/users/verification', {
method: 'put',
body: { email },
})
}
return {
user,
authenticated,
login,
register,
logout,
resendVerificationCode,
requestResetPassword,
resetPassword,
}
}

View File

@@ -0,0 +1,156 @@
import { computed, provide, reactive, unref } from 'vue'
import type { ComputedRef, InjectionKey, MaybeRef } from 'vue'
import { omit } from 'lodash-es'
import dayjs from 'dayjs'
const DATE_FORMAT = 'DD-MM-YYYY'
export interface Filter {
type?: 'select' | 'calendar'
key: string
label: string
placeholder: string
searchable?: boolean
multiple?: boolean
options?: { label?: string, value: unknown }[]
transform?: (value: AppliedFilter) => AppliedFilters
}
export type Filters = Filter[]
export type AppliedFilter = null | string | string[]
export type AppliedFilters = Record<string, AppliedFilter>
export interface FiltersContext {
schema: MaybeRef<Filters>
appliedFiltersRaw: AppliedFilters
appliedFilters: ComputedRef<AppliedFilters>
empty: boolean | ComputedRef<boolean>
apply: (p1: AppliedFilters | string, p2?: AppliedFilter) => void
reset: () => void
}
export const filtersContextKey: InjectionKey<FiltersContext>
= Symbol('FILTERS')
export default (filters: MaybeRef<Filters>) => {
const url = useRequestURL()
const searchString = computed(() => url.search)
const parsedUrl: { other: Record<string, string>, filters: AppliedFilters }
= reactive({
other: {},
filters: {},
})
const allowedFilters = computed<string[]>(() => {
return unref(filters).map(filter => filter.key)
})
parseUrl(searchString.value)
const appliedFiltersRaw = reactive<AppliedFilters>({
...Object.fromEntries(
allowedFilters.value.map(key => [key, isMultiple(key) ? [] : null]),
),
...parsedUrl.filters,
})
const appliedFilters = computed<AppliedFilters>(() => {
return Object.entries(appliedFiltersRaw).reduce((result, [key, value]) => {
const filter = getFilterByKey(key)!
if (filter.transform) {
const transformedValue = filter.transform(value)
if (transformedValue)
result = { ...result, ...transformedValue }
else
result[key] = value
}
else {
result[key] = value
}
return result
}, {} as AppliedFilters)
})
const empty = computed(() =>
Object.values(appliedFiltersRaw).every((value) => {
return Array.isArray(value) ? value.length === 0 : value !== null
}),
)
function parseUrl(searchString: string) {
const params = new URLSearchParams(searchString)
parsedUrl.other = {}
parsedUrl.filters = {}
for (const [key, value] of Array.from(params.entries())) {
if (allowedFilters.value.includes(key)) {
let newValue = isMultiple(key) ? value.split(',') : value
if (isCalendar(key)) {
newValue = [...newValue].map(date =>
dayjs(date, DATE_FORMAT).valueOf().toString(),
)
}
parsedUrl.filters[key] = newValue
}
else {
parsedUrl.other[key] = value
}
}
parsedUrl.other = omit(parsedUrl.other, ['page'])
}
function apply(p1: AppliedFilters | string, p2?: AppliedFilter) {
if (p2 && typeof p1 === 'string') {
appliedFiltersRaw[p1] = p2
}
else if (typeof p1 === 'object') {
for (const [key, value] of Object.entries(p1))
appliedFiltersRaw[key] = value
}
}
function reset() {
for (const key of Object.keys(appliedFiltersRaw))
appliedFiltersRaw[key] = isMultiple(key) ? [] : null
}
function getFilterByKey(key: string) {
return unref(filters).find(f => f.key === key)
}
function isMultiple(key: string) {
const filter = getFilterByKey(key)
return filter?.multiple ?? filter?.type === 'calendar' ?? false
}
function isCalendar(key: string) {
const filter = getFilterByKey(key)
return filter?.type === 'calendar' ?? false
}
provide(filtersContextKey, {
schema: filters,
appliedFiltersRaw,
appliedFilters,
empty,
apply,
reset,
})
return {
appliedFiltersRaw,
appliedFilters,
empty,
apply,
reset,
}
}

View File

@@ -0,0 +1,11 @@
import antfu from '@antfu/eslint-config'
export default await antfu({
overrides: {
vue: {
'vue/block-order': ['error', {
order: ['template', 'script', 'style'],
}],
},
},
})

View File

@@ -0,0 +1,23 @@
import type { AlertType } from 'ui-layer/components/alert/types'
import type { UiIcon } from '#build/types/ui/icons'
export function getStatusType(status: string): AlertType {
switch (status) {
case 'completed':
return 'positive'
case 'expired':
return 'negative'
default:
return 'warning'
}
}
export function getStatusIcon(status: string): UiIcon {
switch (status) {
case 'completed':
return 's-check'
case 'expired':
return 's-cross'
default:
return 's-clock'
}
}

View File

@@ -0,0 +1,5 @@
export default defineI18nConfig(() => ({
legacy: false,
locale: 'en',
globalInjection: true,
}))

7
apps/client/index.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module '#app' {
interface PageMeta {
centerContent?: boolean
}
}
export {}

122
apps/client/lang/en.js Normal file
View File

@@ -0,0 +1,122 @@
export default {
validation: {
required: 'This field is required',
email: 'Enter an email address in the format example{\'@\'}xxxx.xxx',
confirmed: 'Passwords must match',
password:
'Password must contain at least 8 characters, including uppercase letters, numbers, and special characters',
max: 'The value must be less than or equal to {0} characters.',
url: 'Please enter a valid URL',
},
field_max_characters: '{0} (maximum {1} characters)',
invoice_status: {
completed: 'Success',
expired: 'Expired',
pending: 'Pending',
awaiting_payment: 'Awaiting',
investigating: 'Investigating',
broadcasted: 'Broadcasted',
overpaid: 'Overpaid',
underpaid: 'Underpaid',
},
something_went_wrong: 'Something went wrong',
invalid_otp_code: 'Invalid OTP Code',
create: 'Create',
change: 'Change',
select: 'Select',
register: 'Register',
login: 'Login',
privacy_policy: 'Privacy Policy',
copyright: '© {year}, Indefiti - the best crypto processing',
support: 'Support',
network: 'Network',
maximum: 'Maximum',
continue: 'Continue',
fee: 'Fee',
login_greeting: 'Hello!',
register_greeting: 'Register with Indefiti',
to_main: 'To home page',
back: 'Back',
back_to: 'Back to {0}',
next: 'Next',
withdraw: 'Withdraw',
email: 'E-mail',
password: 'Password',
repeat_password: 'Repeat Password',
what_happened: 'What happened?',
what_todo: 'I don\'t remember what to do',
write_us: 'Write to us',
reset_password: 'Reset Password',
lost_password: 'Forgot Password?',
can_reset_password: 'Password can be reset',
fill_the_form: 'fill out this form',
reset_password_alert:
'For security reasons, withdrawals will be prohibited for 24 hours after changing the password',
check_email: 'Check your e-mail',
email_confirmation: 'We have sent you an e-mail to {0}',
register_email_instructions:
'To confirm your email,<br>follow the link in the email',
login_email_instructions:
'To reset your password, follow the link in the email',
send_again: 'Send again',
spam: 'Spam',
create_your_project: {
title: '{0} your first project',
create: 'Create',
content: 'This is necessary to accept payments on the site',
},
your_invoices_will_be_displayed_here: {
title: 'Your invoices will be displayed here',
content: 'Click on the create invoice button to start invoicing',
},
create_an_invoice: 'Сreate an invoice',
forgot_password_questions: {
did_not_get_mail: {
title: 'Check the {0} folder if you don\'t see the email',
content: 'Didn\'t receive the email? {0}',
},
forgot_password: {
title: 'Forgot my password',
content:
'{0}, if you have access to your email. If you don\'t have access, {1}.',
},
access_to_mail: {
title: 'No access to email',
content: 'If you don\'t have access to your email, {0}.',
},
no_mail: {
title: 'The website says there is no such email',
content:
'Check which email you received receipts and other notifications to. If you are sure you are entering everything correctly, {0}.',
},
},
sign_up_agreement: {
base: 'I confirm my agreement with the {0} and {1}',
privacy_policy: 'Privacy Policy',
user_agreement: 'User Agreement',
},
apply: 'Apply',
try: 'Try',
test_functional: 'Test functional',
learn_functional:
'Here you can familiarize yourself with the main features. To get started with payments, create your first project.',
how_does_it_works: 'How does it works',
projects: 'Projects',
account_created_successfully: 'Account created successfully',
account_verification_error: 'An error occurred during account verification',
try_again_or_contact_support: 'Try again or contact support',
we_have_sent_you_an_email_to: 'We have sent you an email to {0}',
check_spam_folder: 'Check the {0} folder if you do not see the letter',
did_not_get_mail: 'Did not get the email?',
reset_password_instructions:
'To reset your password, follow the link from the letter',
}

1
apps/client/lang/ru.js Normal file
View File

@@ -0,0 +1 @@
export default {}

View File

@@ -0,0 +1,131 @@
<template>
<div class="auth-layout">
<header class="auth-header">
<NuxtLink to="/projects">
<img
src="/logo.svg"
alt="logo"
draggable="false"
>
</NuxtLink>
<UiButton
v-if="headerAction"
class="ml-a"
type="ghost"
:href="headerAction.link"
size="large"
>
{{ headerAction.name }}
</UiButton>
<LangSwitcher />
</header>
<main class="auth-main">
<slot />
</main>
<footer class="auth-footer">
<div class="auth-footer__left">
<UiButton
type="link"
color="secondary"
href="#"
>
{{ $t('privacy_policy') }}
</UiButton>
</div>
<div class="auth-footer__middle">
<span class="auth-footer__copyright">
{{ $t('copyright', { year }) }}
</span>
</div>
<div class="auth-footer__right">
<UiButton
icon="circle-question"
type="link"
color="secondary"
href="#"
>
{{ $t('support') }}
</UiButton>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { t } = useI18n({ useScope: 'global' })
const year = computed(() => new Date().getFullYear())
const headerAction = computed(() => {
switch (route.name) {
case 'login':
return {
name: t('register'),
link: '/register',
}
case 'register':
return {
name: t('login'),
link: '/login',
}
default:
return undefined
}
})
</script>
<style lang="scss">
.auth-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.auth-header {
display: flex;
padding: 32px 56px 0 56px;
justify-content: space-between;
align-items: center;
}
.auth-main {
flex: 1;
display: flex;
align-items: center;
padding-block: 30px;
}
.auth-footer {
padding: 24px 56px 32px;
display: flex;
justify-content: space-between;
align-items: center;
> * {
flex: 1;
}
&__left {
text-align: start;
}
&__middle {
text-align: center;
}
&__right {
text-align: end;
}
&__copyright {
color: $clr-grey-400;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div class="default-layout">
<NavigationSidebar>
<template #logo>
<NavigationLogo />
</template>
<template #default>
<NavigationItem
v-for="item in navItems"
:key="item.to"
:to="item.to"
:icon="item.icon"
:title="item.title"
:matcher="item.matcher ?? defaultMatcher(item.to)"
/>
</template>
<template #bottom>
<NavigationItem
icon="info"
title="Help"
to="/_"
:matcher="() => ['/help'].includes(route.fullPath)"
/>
</template>
</NavigationSidebar>
<main
class="default-layout__main"
:class="{
center: shouldCenterContent,
narrow: isAnySidebarOpened,
}"
>
<div class="container">
<slot />
</div>
</main>
<ModalsContainer />
</div>
</template>
<script setup>
import { ModalsContainer, useVfm } from 'vue-final-modal'
const { t } = useI18n()
const route = useRoute()
const { openedModals } = useVfm()
const defaultMatcher = link => () => route.fullPath === link
const navItems = computed(() => [
{
icon: 'merchant',
title: t('projects'),
to: '/projects',
matcher: () =>
route.fullPath === '/projects'
|| route.fullPath.startsWith('/projects')
|| route.meta.alias?.startsWith('/projects'),
},
])
const shouldCenterContent = computed(() => route.meta.centerContent)
const isAnySidebarOpened = computed(() => openedModals.some(modal => modal.value.modalId.startsWith('sidebar') && modal.value.overlayVisible.value))
</script>
<style lang="scss">
.default-layout {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 120px 1fr;
grid-area: sidebar;
overflow-y: hidden;
z-index: 1;
&__main {
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 100%;
padding: 0 35px 32px;
transition: padding-right .2s ease-in-out;
&.center {
padding-top: 32px;
> .container {
margin-block: auto;
}
}
&.narrow {
padding-right: calc(353px + 35px);
}
&:not(.center) {
> .container {
display: flex;
flex-direction: column;
flex: 1;
}
}
> .container {
width: 100%;
max-width: 1250px;
margin: 0 auto;
}
}
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div class="empty-layout">
<slot />
</div>
</template>

View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
if (to.path === '/')
return navigateTo('/projects')
if (to.path !== '/' && to.path.endsWith('/'))
return navigateTo(to.path.slice(0, -1))
})

View File

@@ -0,0 +1,17 @@
import type { User } from '~/composables/use-auth'
export default defineNuxtRouteMiddleware(async (to, from) => {
const session = useCookie('session')
const { authenticated, user } = useAuth()
if (session.value && !authenticated.value) {
try {
user.value = await $api<User>('/users/current', {
method: 'get',
})
}
catch (e) {
console.log(e)
}
}
})

View File

@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
const { authenticated } = useAuth()
if (!authenticated.value)
return navigateTo('/login')
})

View File

@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(() => {
const { authenticated } = useAuth()
if (authenticated.value)
return navigateTo('/projects')
})

View File

@@ -0,0 +1,48 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
extends: ['../../layers/shared', '../../layers/ui'],
modules: [
'@pinia/nuxt',
[
'@nuxtjs/i18n',
{
vueI18n: './i18n.config.ts',
lazy: true,
langDir: 'lang',
compilation: {
strictMessage: false,
},
locales: [
// {
// code: 'ru',
// name: 'Русский',
// file: 'ru.js',
// },
{
code: 'en',
name: 'English',
file: 'en.js',
},
],
defaultLocale: 'en',
strategy: 'no_prefix',
detectBrowserLanguage: false,
},
],
],
css: ['~/assets/styles.scss', 'vue-final-modal/style.css'],
runtimeConfig: {
public: {
host: process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: 'https://app.prgms.io',
payHost: process.env.NODE_ENV === 'development'
? 'http://localhost:3001'
: 'https://pay.prgms.io',
apiHost:
process.env.NODE_ENV === 'development'
? '/api'
: 'https://api.prgms.io/api/v1',
},
},
})

35
apps/client/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "client",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"dependencies": {
"@pinia/nuxt": "^0.4.11",
"@vueuse/core": "^10.7.0",
"dayjs": "^1.11.10",
"decimal.js": "^10.4.3",
"defu": "^6.1.2",
"ufo": "^1.3.2",
"ui-layer": "*",
"shared-layer": "*",
"uuid": "^9.0.1",
"vue-final-modal": "^4.4.6"
},
"devDependencies": {
"@antfu/eslint-config": "^2.1.2",
"@nuxt/devtools": "latest",
"@nuxtjs/i18n": "^8.0.0-rc.5",
"eslint": "^8.54.0",
"nuxt": "latest",
"sass": "^1.69.0",
"unplugin-vue-components": "^0.25.2",
"vue": "latest",
"vue-router": "^4.2.5"
}
}

View File

@@ -0,0 +1,94 @@
<template>
<form class="twofa" @submit="onSubmit">
<UiButton
icon="chevron-left"
color="secondary"
type="link"
class="mb-8"
href="/projects"
>
{{ $t('to_main') }}
</UiButton>
<h1 class="mb-8">
Проверка безопасности
</h1>
<UiAlert class="mb-16">
Введите 6-значный код подтверждения, отправленный на jo**@gmail.com
</UiAlert>
<UiCodeInput id="code">
<template #title>
<span class="twofa__title"> Код из письма </span>
</template>
</UiCodeInput>
<div class="text-align-right mt-8">
<UiButton v-if="!showTimer" type="link" @click="resetTimer">
Отправить код еще раз
</UiButton>
<div v-else class="twofa__timer">
<span>Отправить код повторно через 0:{{ seconds }}</span>
</div>
</div>
<UiButton class="twofa__submit" size="large" native-type="submit">
{{ $t('next') }}
</UiButton>
</form>
</template>
<script setup>
import { useForm } from 'vee-validate'
definePageMeta({
middleware: ['auth'],
centerContent: true,
})
const seconds = ref(30)
const showTimer = ref(false)
const { handleSubmit, isSubmitting } = useForm()
const onSubmit = handleSubmit(async (values) => {
console.log(values)
navigateTo('/2fa/setup')
})
function resetTimer() {
showTimer.value = true
const timer = setInterval(() => {
if (seconds.value === 0) {
clearInterval(timer)
showTimer.value = false
seconds.value = 30
}
else {
seconds.value--
}
}, 1000)
}
</script>
<style lang="scss">
.twofa {
margin: 0 auto;
width: 500px;
&__title {
@include txt-m-b;
}
&__submit {
margin-top: 32px;
width: 100%;
}
&__timer {
@include txt-i;
color: $clr-grey-500;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<form
class="twofa-setup"
@submit="onSubmit"
>
<UiButton
icon="chevron-left"
color="secondary"
type="link"
class="mb-8"
:href="step === 0 ? '/projects' : ''"
@click="step--"
>
{{ step === 0 ? $t('to_main') : $t('back') }}
</UiButton>
<h1 class="twofa-setup__title">
Настройка двухфакторной аутентификации (2FA)
</h1>
<Stepper
class="mb-40"
:step="step"
:items="['Установка', 'Настройка', 'Ввод кода']"
/>
<Transition name="fade" mode="out-in">
<div v-if="step === 0">
<UiAlert class="mb-47">
Установите приложение Google Authenticator или другое из списка, чтобы
получить коды двухфакторной аутентификации.
</UiAlert>
<div class="twofa-setup__app-block">
<p class="mb-8">
Google Authenticator app
</p>
<div class="mb-24">
<UiButton
color="secondary"
type="outlined"
class="mr-24"
>
<img
class="d-flex"
src="/GetOnAppStore.svg"
alt="GetOnAppStore"
>
</UiButton>
<UiButton
color="secondary"
type="outlined"
>
<img
class="d-flex"
src="/GetOnGooglePlay.svg"
alt="GetOnGooglePlay"
>
</UiButton>
</div>
<div>
<span>
Посмотреть весь список 2FA приложений -
<UiButton
type="link"
href="https://www.pcmag.com/picks/the-best-authenticator-apps"
target="_blank"
>
подробнее
</UiButton>
</span>
</div>
</div>
<UiButton
class="twofa-setup__submit mt-47"
size="large"
@click="step++"
>
{{ $t('next') }}
</UiButton>
</div>
<div v-else-if="step === 1">
<UiAlert class="mb-24">
В приложении
<strong>двухфакторной аутентификации</strong> отсканируйте QR-код или
скопируйте и вставьте <strong>ключ настройки</strong> с этого экрана
вручную.
</UiAlert>
<div class="twofa-setup__qrzone">
<img src="/QRCode.svg" alt="QRCode" class="mr-40">
<UiInput
id="ssa"
model-value="GB5AY4JWLUUF2NRT"
readonly
copyable
label="Ключ настройки"
class="flex-1"
/>
</div>
<UiButton
class="twofa-setup__submit mt-32"
size="large"
@click="step++"
>
{{ $t('next') }}
</UiButton>
</div>
<div v-else-if="step === 2">
<UiAlert class="mb-50">
Далее введите на этом экране <strong>шестизначный код</strong> из
приложения двухфакторной аутентификации
</UiAlert>
<UiCodeInput
id="pincode"
class="mb-21"
>
<template #title>
<span class="twofa-setup__pin-title"> 2FA-код </span>
</template>
</UiCodeInput>
<UiButton
class="twofa-setup__submit mt-32"
size="large"
native-type="submit"
>
{{ $t('next') }}
</UiButton>
</div>
</Transition>
</form>
</template>
<script setup>
import { useForm } from 'vee-validate'
definePageMeta({
middleware: ['auth'],
centerContent: true,
})
const { handleSubmit, isSubmitting } = useForm()
const step = ref(0)
const onSubmit = handleSubmit(async (values) => {
console.log(values)
navigateTo('/projects')
})
</script>
<style lang="scss">
.twofa-setup {
margin: 0 auto;
width: 500px;
&__qrzone {
display: flex;
align-items: center;
justify-content: space-between;
}
&__app-block {
}
&__title {
margin-bottom: 56px;
}
&__pin-title {
@include txt-m-b;
}
&__submit {
//margin-top: 47px;
width: 100%;
}
}
</style>

833
apps/client/pages/_.vue Normal file
View File

@@ -0,0 +1,833 @@
<template>
<div
ref="root"
class="root"
>
<!-- UiButton -->
<div class="doc-block">
<strong>UiButton</strong>
<h1>Кнопки и ссылки</h1>
<div style="display: flex; flex-direction: column; gap: 16px">
<template
v-for="type in ['filled', 'outlined', 'ghost', 'link']"
:key="type"
>
<div
v-for="color in ['primary', 'secondary']"
:key="color"
class="buttons-rows"
>
<div
v-for="size in ['large', 'medium', 'small']"
:key="size"
class="buttons-grid"
>
<template
v-for="(props, index) in [
{
bind: { type, size, color },
icon: size === 'large' ? 'plus' : 's-plus',
chevron:
size === 'large' ? 'chevron-down' : 's-chevron-down',
},
]"
:key="index"
>
<UiButton v-bind="props.bind">
Button
</UiButton>
<UiButton
v-bind="props.bind"
:icon="props.icon"
>
Button
</UiButton>
<UiButton
v-bind="props.bind"
:right-icon="props.icon"
>
Button
</UiButton>
<UiButton
v-bind="props.bind"
:left-icon="props.icon"
:right-icon="props.chevron"
>
Button
</UiButton>
<UiButton
v-bind="props.bind"
loading
>
Button
</UiButton>
<UiButton
v-bind="props.bind"
:icon="props.icon"
/>
<UiButton
v-bind="props.bind"
:left-icon="props.icon"
:right-icon="props.chevron"
/>
<UiButton
v-bind="props.bind"
:icon="props.icon"
loading
/>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- UiSelect -->
<div class="doc-block">
<strong>UiSelect</strong>
<h1>Select</h1>
<div class="two-columns">
<h4 class="group-heading">
Default
</h4>
<h4 class="group-heading">
Multiple
</h4>
<div
v-for="(multiple, index) in [false, true]"
:key="index"
class="bg-clr-grey-100 p-8"
style="display: inline-flex; gap: 8px; justify-self: center"
>
<UiSelect
:id="!multiple ? 'select' : 'select2'"
label="Select"
:options="selectOptions"
:multiple="multiple"
/>
<UiSelect
:id="!multiple ? 'select' : 'select2'"
label="Searchable"
:options="selectOptions"
searchable
:multiple="multiple"
/>
<UiSelect
:id="!multiple ? 'select' : 'select2'"
label="Clearable"
:options="selectOptions"
clearable
:multiple="multiple"
/>
</div>
</div>
</div>
<!-- UiDropdown -->
<div class="doc-block half">
<strong>UiDropdown</strong>
<h1>Dropdown \ Выпадающий список</h1>
<UiDropdown>
<UiButton
right-icon="s-chevron-down"
size="small"
>
Trigger
</UiButton>
<template #dropdown>
<UiDropdownItem
v-for="i in 5"
:key="i"
>
Item {{ i }}
</UiDropdownItem>
</template>
</UiDropdown>
</div>
<!-- UiCalendar -->
<div class="doc-block half">
<strong>UiCalendar</strong>
<h1>Calendar</h1>
<div class="d-inline-block bg-clr-grey-100 p-8">
<UiCalendar style="border-radius: 12px" />
</div>
</div>
<!-- UiInput -->
<div class="doc-block half">
<strong>UiInput</strong>
<h1>Поля ввода</h1>
<div class="two-columns">
<!-- <template v-for="state in []"></template> -->
<h4 class="group-heading">
Normal
</h4>
<h4 class="group-heading">
Disabled
</h4>
<UiInput
id="input1"
label="Basic"
/>
<UiInput
id="input1"
label="Basic"
disabled
/>
<UiInput
id="input2"
label="With caption"
caption="Caption"
/>
<UiInput
id="input2"
label="With caption"
caption="Caption"
disabled
/>
<UiInput
id="input3"
label="Clearable"
clearable
model-value="Value"
/>
<UiInput
id="input3"
label="Clearable"
clearable
disabled
/>
<UiInput
id="input4"
label="Copyable"
copyable
model-value="Value"
/>
<UiInput
id="input4"
label="Copyable"
copyable
disabled
/>
<UiInput
id="input5"
label="Password"
native-type="password"
model-value="superPassword"
/>
<UiInput
id="input5"
label="Password"
native-type="password"
disabled
/>
</div>
</div>
<!-- UiSearch -->
<div class="doc-block half">
<strong>UiSearch</strong>
<h1>Поиск</h1>
<div class="two-columns">
<h4 class="group-heading">
Normal
</h4>
<h4 class="group-heading">
Disabled
</h4>
<UiSearch
v-model="searchTerm"
size="large"
label="Large"
/>
<UiSearch
v-model="searchTerm"
size="large"
label="Large"
disabled
/>
<UiSearch
v-model="searchTerm"
size="medium"
label="Medium"
/>
<UiSearch
v-model="searchTerm"
size="medium"
label="Medium"
disabled
/>
<UiSearch
v-model="searchTerm"
size="small"
label="Small"
/>
<UiSearch
v-model="searchTerm"
size="small"
label="Small"
disabled
/>
</div>
</div>
<!-- UiSwitch -->
<div class="doc-block third">
<strong>UiSwitch</strong>
<h1>Switch</h1>
<div class="two-columns justify-items-center">
<h4 class="group-heading">
Normal
</h4>
<h4 class="group-heading">
Disabled
</h4>
<UiSwitch id="switch" />
<UiSwitch
id="switch"
disabled
/>
<UiSwitch
id="switch2"
:loading="switchLoading"
:before-change="beforeChange"
/>
<UiSwitch
id="switch2"
:loading="switchLoading"
disabled
/>
</div>
</div>
<!-- UiCheckbox -->
<div class="doc-block third">
<strong>UiCheckbox</strong>
<h1>Checkbox</h1>
<div class="two-columns">
<h4 class="group-heading">
Normal
</h4>
<h4 class="group-heading">
Disabled
</h4>
<UiCheckbox
id="checkbox"
label="Label"
true-value="checkbox1"
/>
<UiCheckbox
id="checkbox"
label="Label"
true-value="checkbox1"
disabled
/>
<UiCheckbox
id="checkbox"
true-value="checkbox2"
/>
<UiCheckbox
id="checkbox"
true-value="checkbox2"
disabled
/>
</div>
</div>
<!-- UiRadio -->
<div class="doc-block third">
<strong>UiRadio</strong>
<h1>Radio</h1>
<div class="two-columns">
<h4 class="group-heading">
Normal
</h4>
<h4 class="group-heading">
Disabled
</h4>
<UiRadio
id="radio"
label="Label"
value="radio1"
/>
<UiRadio
id="radio"
label="Label"
value="radio1"
disabled
/>
<UiRadio
id="radio"
value="radio2"
/>
<UiRadio
id="radio"
value="radio2"
disabled
/>
</div>
</div>
<!-- UiAccordion -->
<div class="doc-block">
<strong>UiAccordion</strong>
<h1>Спойлер \ Аккордеон</h1>
<div style="width: 600px; margin: 0 auto">
<UiAccordion>
<UiAccordionItem title="Title">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad
commodi, culpa delectus deleniti doloribus ducimus eaque eos eveniet
expedita harum, in incidunt itaque magnam, nulla quam ratione
recusandae reprehenderit veniam?
</UiAccordionItem>
<UiAccordionItem title="Title 2">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolor
exercitationem iusto obcaecati placeat sunt! Dicta dolorem ducimus
minima molestias obcaecati odio omnis ratione recusandae,
voluptatum? Aspernatur autem commodi praesentium sint? Lorem ipsum
dolor sit amet, consectetur adipisicing elit. Expedita optio, vitae.
Aut consectetur cum dolores est exercitationem facilis fugiat
incidunt iste maiores modi nobis quia rerum, sit vel veniam vero!
<br>
<br>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolor
exercitationem iusto obcaecati placeat sunt! Dicta dolorem ducimus
minima molestias obcaecati odio omnis ratione recusandae,
voluptatum? Aspernatur autem commodi praesentium sint? Lorem ipsum
dolor sit amet, consectetur adipisicing elit. Expedita optio, vitae.
Aut consectetur cum dolores est exercitationem facilis fugiat
incidunt iste maiores modi nobis quia rerum, sit vel veniam vero!
</UiAccordionItem>
<UiAccordionItem title="Title 3">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolor
exercitationem iusto obcaecati placeat sunt! Dicta dolorem ducimus
minima molestias obcaecati odio omnis ratione recusandae,
voluptatum? Aspernatur autem commodi praesentium sint? Lorem ipsum
dolor sit amet, consectetur adipisicing elit. Expedita optio, vitae.
Aut consectetur cum dolores est exercitationem facilis fugiat
incidunt iste maiores modi nobis quia rerum, sit vel veniam vero!
<br>
<br>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Dolor
exercitationem iusto obcaecati placeat sunt! Dicta dolorem ducimus
minima molestias obcaecati odio omnis ratione recusandae,
voluptatum? Aspernatur autem commodi praesentium sint? Lorem ipsum
dolor sit amet, consectetur adipisicing elit. Expedita optio, vitae.
Aut consectetur cum dolores est exercitationem facilis fugiat
incidunt iste maiores modi nobis quia rerum, sit vel veniam vero!
</UiAccordionItem>
</UiAccordion>
</div>
</div>
<!-- UiAlert -->
<div class="doc-block">
<strong>UiAlert</strong>
<h1>Алерты</h1>
<div
class="three-columns"
style="justify-items: center"
>
<div
class="w-100"
style="max-width: 400px"
>
<UiAlert
v-for="type in [
'neutral',
'positive',
'warning',
'negative',
'marketing',
]"
:key="type"
class="my-8"
:type="type"
text="Текст сообщения"
/>
</div>
<div
class="w-100"
style="max-width: 400px"
>
<UiAlert
v-for="type in [
'neutral',
'positive',
'warning',
'negative',
'marketing',
]"
:key="type"
class="my-8"
:type="type"
title="Заголовок"
text="Текст сообщения"
/>
</div>
<div
class="w-100"
style="max-width: 400px"
>
<UiAlert
v-for="type in [
'neutral',
'positive',
'warning',
'negative',
'marketing',
]"
:key="type"
:type="type"
class="my-8"
title="Заголовок"
text="Текст сообщения"
>
<template #action>
<UiButton type="link">
Действие
</UiButton>
</template>
</UiAlert>
</div>
</div>
</div>
<!-- UiBadge -->
<div class="doc-block">
<strong>UiBadge</strong>
<h1>Бейджы</h1>
<div
class="three-columns"
style="justify-items: center"
>
<div>
<UiBadge
v-for="type in [
'neutral',
'positive',
'warning',
'negative',
'marketing',
]"
:key="type"
class="my-8"
:type="type"
text="Текст бейджа"
/>
</div>
<div>
<UiBadge
v-for="type in [
'neutral',
'positive',
'warning',
'negative',
'marketing',
]"
:key="type"
class="my-8"
:type="type"
text="Текст бейджа"
:icon="randomIcon()"
/>
</div>
<div>
<UiBadge
v-for="type in [
'neutral',
'positive',
'warning',
'negative',
'marketing',
]"
:key="type"
:type="type"
class="my-8"
text="Текст бейджа"
>
<template #prefix>
<i :class="`icon-${randomIcon()}`" />
</template>
<template #suffix>
<i :class="`icon-${randomIcon()}`" />
</template>
</UiBadge>
</div>
</div>
</div>
<!-- Notification -->
<div class="doc-block">
<strong>useNotify \ $notify</strong>
<h1>Уведомления</h1>
<div
style="
display: grid;
grid-template-columns: repeat(2, 150px);
justify-content: center;
gap: 8px;
"
>
<UiButton @click="randomNotification('top-left')">
Top left
</UiButton>
<UiButton @click="randomNotification('top-right')">
Top Right
</UiButton>
<UiButton @click="randomNotification('bottom-left')">
Bottom left
</UiButton>
<UiButton @click="randomNotification('bottom-right')">
Bottom Right
</UiButton>
</div>
</div>
<!-- CopyButton -->
<div class="doc-block">
<strong>UiCopyButton</strong>
<h1>Кнопка копирования</h1>
<div
style="
display: grid;
grid-template-columns: repeat(1, 150px);
justify-content: center;
gap: 8px;
"
>
<UiCopyButton
title="Payment link"
pressed-title="Link copied"
value="Test value"
/>
</div>
</div>
<!-- Icons -->
<!-- <div -->
<!-- v-for="(font, index) in [icons, smallIcons]" -->
<!-- :key="font.metadata.name" -->
<!-- class="doc-block half" -->
<!-- > -->
<!-- <strong> -->
<!-- {{ _ === 0 ? '<i class="icon-*" />' : '<i class="icon-s-*" />' }} -->
<!-- </strong> -->
<!-- <h1>Иконки - {{ font.metadata.name }}</h1> -->
<!-- <div> -->
<!-- <div -->
<!-- v-for="(iconSet, index) in font.iconSets" -->
<!-- :key="_" -->
<!-- class="icons-grid" -->
<!-- > -->
<!-- <div -->
<!-- v-for="icon in iconSet.selection" -->
<!-- :key="icon.name" -->
<!-- class="preview-icon" -->
<!-- @click="copy(icon.name)" -->
<!-- > -->
<!-- <i :class="`${font.preferences.fontPref.prefix}${icon.name}`" /> -->
<!-- <span>{{ icon.name }}</span> -->
<!-- </div> -->
<!-- </div> -->
<!-- </div> -->
<!-- </div> -->
</div>
</template>
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { ALERT_TYPES } from 'ui-layer/components/alert/types'
import type { NotificationPlacement } from 'ui-layer/components/notification/types'
import { sample } from 'lodash-es'
import icons from '#build/ui/available-icons'
definePageMeta({
layout: 'empty',
dev: true,
})
const searchTerm = ref<string>()
const switchLoading = ref(false)
useForm()
function copy(value: string) {
navigator.clipboard.writeText(value)
}
async function beforeChange() {
switchLoading.value = true
const result = await new Promise(resolve =>
setTimeout(() => resolve(true), 1000),
)
switchLoading.value = false
return result
}
const selectOptions = computed(() => {
return Array.from(Array(300).keys()).map(i => `Option ${i + 1}`)
})
let notificationTypeCounter = 0
function randomNotification(placement: NotificationPlacement) {
const notify = useNotify()
notify({
placement,
text: 'Текст',
title: 'Заголовок',
type: ALERT_TYPES[notificationTypeCounter],
})
notificationTypeCounter++
notificationTypeCounter %= ALERT_TYPES.length
}
function randomIcon() {
return sample(icons.filter(icon => icon.startsWith('s-')))
}
</script>
<style lang="scss" scoped>
.root {
padding: 30px;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 30px;
}
.doc-block {
background-color: $clr-white;
border-radius: 30px;
padding: 40px 60px;
grid-column: span 6;
&.third {
grid-column: span 2;
}
&.half {
grid-column: span 3;
}
> strong {
color: $clr-grey-600;
}
> h1 {
margin-bottom: 40px;
}
}
.separator {
width: 100%;
}
.two-columns {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.three-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.buttons-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.buttons-grid {
display: flex;
gap: 6px;
}
.icons-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.preview-icon {
display: flex;
border-radius: 8px;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
width: 85px;
height: 65px;
text-align: center;
background-color: $clr-grey-100;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease-out;
&:hover,
&:focus {
background-color: $clr-grey-200;
}
&:active {
background-color: $clr-grey-300;
}
span {
@include txt-s-m;
}
}
.group-heading {
text-align: center;
color: $clr-grey-500;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="create-invoice">
<Transition
name="fade"
mode="out-in"
>
<PageForm
v-if="!invoiceLink"
title="Creating invoice"
:back-link="`/projects/${$route.params.projectId}/invoices`"
:back-text="$t('back_to', [project.name])"
:submit-text="$t('create')"
:handler="onSubmit"
>
<UiInput
id="amount"
label="Invoice amount"
rules="required"
:mask="{ mask: Number }"
>
<template #suffix>
<CurrencyName
code="USDT"
size="small"
/>
</template>
</UiInput>
</PageForm>
<div
v-else
class="create-invoice__summary"
>
<UiButton
icon="chevron-left"
color="secondary"
type="link"
class="mb-8"
:href="`/projects/${$route.params.projectId}/invoices`"
>
{{ $t('back_to', [project.name]) }}
</UiButton>
<h1 class="mb-32">
Invoice created
</h1>
<UiAlert
type="neutral"
class="mb-24"
>
<span> Creation time</span>
{{ dayjs().format('DD.MM.YY HH:mm') }}.
<span>It will expires in </span>
<strong>30 minutes.</strong>
</UiAlert>
<UiInput
id="invoice_link"
copyable
inputmode="none"
label="Ссылка на счет"
:model-value="invoiceLink"
readonly
/>
<UiButton
size="large"
class="w-100 mt-24"
:href="`/projects/${route.params.projectId}/invoices`"
>
Done
</UiButton>
</div>
</Transition>
</div>
</template>
<script setup>
import { v4 as uuidv4 } from 'uuid'
import dayjs from 'dayjs'
definePageMeta({
middleware: ['auth'],
centerContent: true,
})
const route = useRoute()
const { data: project } = await useAsyncData(
'project',
() =>
$api(`/online_stores/${route.params.projectId}`, {
method: 'get',
}),
{},
)
if (!project.value) {
throw createError({
statusCode: 404,
statusMessage: 'Page Not Found',
})
}
const notify = useNotify()
const runtimeConfig = useRuntimeConfig()
const invoiceLink = ref('')
async function onSubmit(values) {
try {
const result = await $api('/invoices', {
method: 'post',
body: {
...values,
currencyCode: 'USDT',
onlineStoreId: +route.params.projectId,
orderId: uuidv4(),
},
})
invoiceLink.value = `${runtimeConfig.public.payHost}/${result.invoiceId}`
notify({
type: 'positive',
text: 'Инвойс успешно создан',
})
}
catch (e) {
setStaticError({
status: e.status,
message: 'Something went wrong',
})
}
}
</script>
<style lang="scss">
.create-invoice {
&__summary {
width: 500px;
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,451 @@
<template>
<form
class="create-withdraw"
@submit="onSubmit"
>
<Transition
name="shift"
mode="out-in"
>
<div
v-if="!showSummary"
class="withdraw-form"
title="Withdrawal request"
>
<FormHeader
class="withdraw-form__header"
title="Withdrawal request"
:back-link="`/projects/${$route.params.projectId}`"
:back-text="$t('back_to', [project.name])"
/>
<div class="d-flex align-items-center justify-content-between mb-16">
<CurrencyName
:code="route.params.assetCode"
name="Tether"
class="create-withdraw__currency-name"
/>
<p class="create-withdraw__balance">
{{ balance }}
</p>
</div>
<div
class="p-16 bg-clr-grey-200"
style="border-radius: 16px"
>
<UiInput
id="paymentAddress"
rules="required"
label="Withdrawal wallet"
/>
<NetworkSelect
id="network"
:asset-code="route.params.assetCode"
model-value="TRC20"
class="mt-6"
/>
</div>
<div
class="mt-16 p-16 bg-clr-grey-200"
style="border-radius: 16px"
>
<UiInput
id="amount"
rules="required"
:label="`Минимальная сумма ${minAmount} ${route.params.assetCode}`"
:mask="{
mask: Number,
min: minAmount,
max: maxAmount,
scale: exponent,
}"
>
<template #suffix>
<UiButton
type="link"
size="small"
@click="setValues({ amount: maxAmount }, false)"
>
{{ $t('maximum') }}
</UiButton>
</template>
<template #caption>
<div class="create-withdraw__limit">
<span>Доступный лимит на вывод - 1000 USDT</span>
<UiButton
class="create-withdraw__upgrade"
type="link"
size="small"
href="/verification"
>
Повысить лимит
</UiButton>
</div>
</template>
</UiInput>
</div>
<div class="withdraw-form__bottom">
<div class="withdraw-amount withdraw-amount--fee">
<div class="withdraw-amount__title">
{{ $t('fee') }}
</div>
<div class="withdraw-amount__value">
{{ fee }}
</div>
</div>
<div class="withdraw-amount">
<div class="withdraw-amount__title">
К отправке
</div>
<div class="withdraw-amount__value">
{{ total }}
</div>
</div>
<UiButton
size="large"
class="create-withdraw__continue"
native-type="submit"
:disabled="balance <= 0"
>
{{ $t('continue') }}
</UiButton>
</div>
</div>
<div
v-else
class="withdraw-summary"
>
<FormHeader
class="withdraw-summary__header"
title="Confirm the withdrawal"
:back-link="`/projects/${$route.params.projectId}`"
:back-text="$t('back_to', [project.name])"
/>
<dl class="withdraw-summary__details withdraw-details">
<dt>Withdrawal wallet</dt>
<dd>
<TextShortener :text="values.paymentAddress" />
</dd>
<dt>Network</dt>
<dd class="text-clr-green-500">
<CurrencyName
:code="route.params.assetCode"
size="small"
/>
</dd>
<dt>{{ $t('fee') }}</dt>
<dd>{{ fee }}</dd>
<dt>К отправке</dt>
<dd class="withdraw-details__amount">
{{ total }}
</dd>
</dl>
<UiAlert class="withdraw-summary__alert">
Пожалуйста, подтвердите, что операцию инициировали именно вы.
</UiAlert>
<UiCodeInput
id="pincode"
class="withdraw-summary__pincode"
title="2FA-код"
/>
<div class="withdraw-summary__actions">
<UiButton
color="secondary"
size="large"
@click="showSummary = false"
>
{{ $t('back') }}
</UiButton>
<UiButton
size="large"
native-type="submit"
:loading="isSubmitting"
>
{{ $t('withdraw') }}
</UiButton>
</div>
</div>
</Transition>
</form>
</template>
<script setup>
import { v4 as uuidv4 } from 'uuid'
import { useForm } from 'vee-validate'
import { computedAsync } from '@vueuse/core'
import Decimal from 'decimal.js'
definePageMeta({
middleware: ['auth'],
centerContent: true,
})
const route = useRoute()
const { data: project } = await useAsyncData(
'project',
() => {
return $api(`/online_stores/${route.params.projectId}`, {
method: 'GET',
})
},
)
if (!project.value) {
throw createError({
statusCode: 404,
statusMessage: 'Page Not Found',
})
}
const notify = useNotify()
const { t } = useI18n()
const { data: tariff } = await useAsyncData('tariff', () => {
return $api(`/tariffs/current?currency=${route.params.assetCode}`)
})
const { data: account } = await useAsyncData('account', () =>
$api(`/accounts/${project.value.accountIds[0]}`, {
method: 'GET',
}))
const showSummary = ref(false)
const { isSubmitting, values, setValues, handleSubmit } = useForm({ keepValuesOnUnmount: true })
const commission = computedAsync(async () => {
if (values.amount) {
try {
const result = await $api(`/withdrawals/commission`, { method: 'GET', params: { amount: values.amount, currency: route.params.assetCode } })
return result?.commission ?? 0
}
catch {
return 0
}
}
else {
return 0
}
}, 0, { lazy: true })
const fee = computed(() => $money.fullFormat(commission.value, route.params.assetCode))
const balance = computed(() => $money.format(account.value.balance, route.params.assetCode))
const maxAmount = computed(() => Math.min(1000, account.value.balance))
const minAmount = computed(() => tariff.value?.data.minAmount)
const exponent = computed(() => $money.getExponent(route.params.assetCode))
const total = computed(() => $money.fullFormat(Decimal.sub(values.amount || 0, commission.value), route.params.assetCode))
const onSubmit = handleSubmit(async (values) => {
if (!showSummary.value) {
showSummary.value = true
return
}
if (values.pincode !== '123123') {
notify({
id: 'withdraw_error',
text: t('invalid_otp_code'),
type: 'negative',
})
return
}
try {
// const idempotencyKey = `${route.params.projectId + values.paymentAddress + route.params.assetCode}TRON${values.amount}`
await $api('/withdrawals', {
method: 'POST',
body: {
...values,
idempotencyKey: uuidv4().substring(0, 20),
amount: +values.amount,
onlineStoreId: +route.params.projectId,
currencyCode: route.params.assetCode,
blockchainCode: 'TRON',
saveWallet: false,
pincode: undefined,
network: undefined,
},
})
notify({
id: 'withdraw_success',
type: 'positive',
text: 'The withdrawal request has been successfully processed',
})
navigateTo(`/projects/${route.params.projectId}`)
}
catch {
notify({
id: 'withdraw_error',
text: t('something_went_wrong'),
type: 'negative',
})
}
})
</script>
<style lang="scss">
.create-withdraw {
&__currency-name {
@include txt-l-sb('currency-name', true);
@include txt-r-m('currency-name-code', true);
--currency-name-gap: 2px 16px;
}
&__balance {
@include txt-l-sb;
}
&__continue {
flex: 1;
}
&__limit {
@include txt-r-m;
display: flex;
gap: 8px;
align-items: center;
color: $clr-grey-500;
margin-top: 8px;
}
}
.withdraw-form,
.withdraw-summary {
width: 500px;
margin: 0 auto;
&__header {
margin-bottom: 32px;
}
}
.withdraw-form {
&__bottom {
display: flex;
gap: 16px;
margin-top: 32px;
}
}
.withdraw-summary {
&__details {
margin-bottom: 16px;
}
&__alert {
margin-bottom: 16px;
}
&__pincode {
padding-block: 16px;
}
&__actions {
display: flex;
gap: 16px;
margin-top: 32px;
> * {
flex: 1;
}
}
}
.withdraw-amount {
$self: &;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
height: 48px;
flex: 0 0 146px;
&--fee {
flex: 0 0 100px
}
&__title {
@include txt-r-b;
color: $clr-grey-500;
}
&__value {
@include txt-l-sb;
white-space: nowrap;
#{$self}--fee & {
@include txt-i-sb;
color: $clr-grey-600;
}
}
& + & {
border-left: 1px solid $clr-grey-300;
padding-left: 16px;
}
}
.withdraw-details {
@include txt-i-m;
@include txt-i-m('text-shortener', true);
@include txt-i-m('currency-name-code', true);
border-radius: 12px;
background-color: $clr-grey-200;
display: grid;
grid-template-columns: repeat(2, 1fr);
row-gap: 16px;
color: $clr-grey-500;
padding: 16px;
dt, dd {
margin: 0;
}
dt {
color: $clr-grey-600;
}
dd {
text-align: right;
}
&__amount {
@include txt-l-sb;
color: $clr-black;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More