diff --git a/.github/workflows/release-gear-idea.yml b/.github/workflows/release-gear-idea.yml
index b5b711754..cc7ab4c38 100644
--- a/.github/workflows/release-gear-idea.yml
+++ b/.github/workflows/release-gear-idea.yml
@@ -1,144 +1,70 @@
-name: 'Gear Idea Release'
-
-on:
- push:
- branches: ['main', 'stable']
- paths:
- - idea/gear/meta-storage/package.json
- - idea/gear/faucet/package.json
- - idea/gear/frontend/package.json
- - idea/gear/squid/package.json
- - idea/gear/explorer/package.json
- workflow_dispatch:
- inputs:
- service:
- type: choice
- description: Service to release
- options:
- - frontend
- - faucet
- - squid
- - meta-storage
- - explorer
- - all
- - backend
- required: true
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
- AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
- AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- AWS_REGION: ${{ secrets.AWS_REGION }}
- KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
-
-jobs:
- check-version:
- runs-on: ubuntu-latest
- outputs:
- skip: ${{ steps.set_skip.outputs.skip }}
- permissions:
- contents: read
- packages: write
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
- with:
- fetch-depth: 2
-
- - name: Set skip=false on workflow_dispatch
- if: ${{ github.event_name == 'workflow_dispatch' }}
- run: echo "skip=false" >> $GITHUB_OUTPUT
-
- - name: Get current version
- if: ${{ github.event_name != 'workflow_dispatch' }}
- id: cur_version
- run: |
- version=$(jq -r .version idea/gear/common/package.json)
- echo "Current version: $version"
- echo "version=$version" >> $GITHUB_OUTPUT
-
- - name: Get previous version
- if: ${{ github.event_name != 'workflow_dispatch' }}
- id: prev_version
- run: |
- version=$(git show HEAD~1:idea/gear/common/package.json | jq -r .version)
- echo "Previous version: $version"
- echo "version=$version" >> $GITHUB_OUTPUT
-
- - name: Compare versions and set SKIP env
- if: ${{ github.event_name != 'workflow_dispatch' }}
- id: set_skip
- run: |
- if [[ "${{ steps.cur_version.outputs.version }}" != "${{ steps.prev_version.outputs.version }}" ]]; then
- echo "skip=false" >> $GITHUB_OUTPUT
- else
- echo "skip=true" >> $GITHUB_OUTPUT
- fi
-
- set-env-tag:
- runs-on: ubuntu-latest
- outputs:
- environment: ${{ steps.get_env.outputs.environment }}
- needs: [check-version]
- if: ${{ needs.check-version.outputs.skip != 'true' }}
- permissions:
- contents: read
- packages: write
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Get environment
- id: get_env
- run: |
- if [ "${{ github.ref }}" == "refs/heads/stable" ]; then
- echo "environment=production" >> $GITHUB_OUTPUT
- else
- echo "environment=staging" >> $GITHUB_OUTPUT
- fi
-
- build-frontend-image:
- runs-on: ubuntu-latest
- needs: [set-env-tag]
- environment: ${{ needs.set-env-tag.outputs.environment }}
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Check if should be built
- id: check
- uses: './.github/actions/should-be-built'
- with:
- patterns: 'frontend,all'
- input: ${{ github.event.inputs.service }}
- event_name: ${{ github.event_name }}
-
- - name: Log in to the github container registry
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/login-action@master
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push "gear-idea-frontend" Docker image
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/build-push-action@master
- with:
- file: idea/gear/frontend/Dockerfile
+分支:取消进行中: true: 真的: 真的克'非 vft'['非 vft']注册表:ghcr.io
+
+取消进行中: true
+推:
+分支:['非 vft']['非 vft']注册表:ghcr.io: 克]: ghcr.io工作流程_调度:]
+取消进行中: true工作流程_调度:真的: 真的
+输入:
+服务:
+类型:选择
+描述:服务发布
+选项:
+- 前端前端前端前端
+必填:真实
+
+并发:
+AWS_ACCESS_KEY_ID 团体${{github.workflow}}-${{github.ref}}{{github.workflow}}-${{github.ref}}
+取消进行中: true: 真的: 真的
+
+环境::::
+注册表:ghcr.io: 克hcr.io: 克hcr.io: 克: ghcr.io
+IMAGE_NAME:${{ github.repository }}: ${{github.repository}}: ${{github.repository}}
+AWS_ACCESS_KEY_ID:${{secrets.AWS_ACCESS_KEY_ID }}: ${{秘密.AWS_ACCESS_KEY_ID}}: ${{秘密.AWS_ACCESS_KEY_ID}}
+AWS_SECRET_ACCESS_KEY:${{秘密.AWS_SECRET_ACCESS_KEY}}: ${{秘密.AWS_SECRET_ACCESS_KEY}}
+AWS_REGION: ${{秘密.AWS_REGION}}: ${{秘密.AWS_REGION}}行动/结帐@v6姓名: 等: ${{秘密.KUBE_CONFIG_DATA}}{{秘密.AWS_REGION}}
+KUBE_CONFIG_DATA: ${{秘密.KUBE_CONFIG_DATA}}: ${{秘密.KUBE_CONFIG_DATA}}: ${{秘密.KUBE_CONFIG_DATA}}: ${{秘密.KUBE_CONFIG_DATA}}职位::${{秘密.KUBE_CONFIG_DATA}}: ${{秘密.KUBE_CONFIG_DATA}}
+
+职位:::
+设置环境标签::行动/结帐@v6姓名: 等: 等环境
+连续运行: 在: 在buntu-最新版: 在: 在buntu-最新版: 在: ubuntu-最新版
+输出:::
+环境: ${{步骤.get_env.outputs.environment}}: ${{步骤.get_env.outputs.environment}}: ${{步骤.get_env.outputs.environment}}: ${{步骤.get_env.outputs.environment}}
+步骤:::
+- 姓名: 签: 签出存储库姓名: 签: 签出存储库
+用途: 姓: 姓: 姓: 姓名
+
+行动/结帐@v6姓名: 等: 等: 等: 等环境
+ID: 克等环境: 克等环境
+跑步: |: |
+echo“环境=生产”>> $GITHUB_OUTPUT
+
+构建前端图像构建前端图像
+连续运行: ubuntu-最新版: ubuntu-最新版
+需要: [设置环境标签]: [设置环境标签]
+环境: ${{need.set-env-tag.outputs.environment}}: ${{need.set-env-tag.outputs.environment}}
+权限::
+内容: r电子头: r电子头
+包: 在喜欢: 在喜欢
+
+步骤::
+- 姓名: 签出存储库姓名: 签出存储库
+用途:: 一个动作/结账@v6
+
+- 姓名用途用途
+用途: 和
+和:
+注册表: ${{环境注册表}}
+用户名: ${{github.actor}}
+密码: ${{秘密.GITHUB_TOKEN}}
+
+ - 日本名字和姓氏
+用途“齿轮创意前端”docker/build-push-action@master
+和:
+名称:“齿轮创意发布”: idea/gear/frontend/Dockerfile
push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-frontend:${{ needs.set-env-tag.outputs.environment }}
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-frontend:${{ needs.set-env-tag.outputs.environment }}-${{ github.sha }}
+ 标签: |
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-frontend:temp
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-frontend:temp-${{ github.sha }}
build-args: |
VITE_NODE_ADDRESS=${{ secrets.REACT_APP_NODE_ADDRESS }}
VITE_DEFAULT_TRANSFER_BALANCE_VALUE=${{ secrets.REACT_APP_DEFAULT_TRANSFER_BALANCE_VALUE }}
@@ -153,168 +79,8 @@ jobs:
VITE_MAINNET_DNS_API_URL=${{ secrets.VITE_MAINNET_DNS_API_URL }}
VITE_CODE_VERIFIER_API_URL=${{ secrets.VITE_CODE_VERIFIER_API_URL }}
- build-faucet-image:
- runs-on: ubuntu-latest
- needs: [set-env-tag]
- environment: ${{ needs.set-env-tag.outputs.environment }}
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Check if should be built
- id: check
- uses: './.github/actions/should-be-built'
- with:
- patterns: 'faucet,all,backend'
- input: ${{ github.event.inputs.service }}
- event_name: ${{ github.event_name }}
-
- - name: Log in to the github container registry
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/login-action@master
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push "gear-idea-faucet" Docker image
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/build-push-action@master
- with:
- file: idea/gear/faucet/Dockerfile
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-faucet:${{ needs.set-env-tag.outputs.environment }}
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-faucet:${{ needs.set-env-tag.outputs.environment }}-${{ github.sha }}
-
- build-meta-storage-image:
- runs-on: ubuntu-latest
- needs: [set-env-tag]
- environment: ${{ needs.set-env-tag.outputs.environment }}
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Check if should be built
- id: check
- uses: './.github/actions/should-be-built'
- with:
- patterns: 'meta-storage,all,backend'
- input: ${{ github.event.inputs.service }}
- event_name: ${{ github.event_name }}
-
- - name: Log in to the github container registry
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/login-action@master
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push "gear-idea-meta-storage" image
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/build-push-action@master
- with:
- file: idea/gear/meta-storage/Dockerfile
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-meta-storage:${{ needs.set-env-tag.outputs.environment }}
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-meta-storage:${{ needs.set-env-tag.outputs.environment }}-${{ github.sha }}
-
- build-squid-image:
- runs-on: ubuntu-latest
- needs: [set-env-tag]
- environment: ${{ needs.set-env-tag.outputs.environment }}
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Check if should be built
- id: check
- uses: './.github/actions/should-be-built'
- with:
- patterns: 'squid,all,backend'
- input: ${{ github.event.inputs.service }}
- event_name: ${{ github.event_name }}
-
- - name: Log in to the github container registry
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/login-action@master
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push "gear-idea-squid" image
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/build-push-action@master
- with:
- file: idea/gear/squid/Dockerfile
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-squid:${{ needs.set-env-tag.outputs.environment }}
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-squid:${{ needs.set-env-tag.outputs.environment }}-${{ github.sha }}
-
- build-explorer-image:
- runs-on: ubuntu-latest
- needs: [set-env-tag]
- environment: ${{ needs.set-env-tag.outputs.environment }}
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- - name: Check if should be built
- id: check
- uses: './.github/actions/should-be-built'
- with:
- patterns: 'explorer,all,backend'
- input: ${{ github.event.inputs.service }}
- event_name: ${{ github.event_name }}
-
- - name: Log in to the github container registry
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/login-action@master
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push "gear-idea-explorer" image
- if: ${{ steps.check.outputs.ok == 'true' }}
- uses: docker/build-push-action@master
- with:
- file: idea/gear/explorer/Dockerfile
- push: true
- tags: |
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-explorer:${{ needs.set-env-tag.outputs.environment }}
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-gear-idea-explorer:${{ needs.set-env-tag.outputs.environment }}-${{ github.sha }}
-
deploy-to-k8s:
- needs:
- [
- set-env-tag,
- build-frontend-image,
- build-faucet-image,
- build-meta-storage-image,
- build-squid-image,
- build-explorer-image,
- ]
+ needs: [build-frontend-image]
runs-on: ubuntu-latest
steps:
@@ -328,30 +94,8 @@ jobs:
- name: Get deployment variables
id: deployment_vars
run: |
- if [ "${{ needs.set-env-tag.outputs.environment }}" == "production" ]; then
- echo "namespace=prod-idea" >> $GITHUB_OUTPUT
- echo "deployments=squid-testnet-v2 squid-mainnet-v2 explorer faucet frontend-nginx meta-storage-main" >> $GITHUB_OUTPUT
- else
- echo "namespace=dev-1" >> $GITHUB_OUTPUT
- if [ "${{ github.event_name }}" != "workflow_dispatch" || "${{ github.event.inputs.service }}" == "all" ]; then
- echo "deployments=squid-testnet-v2 explorer frontend-nginx meta-storage faucet" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.service }}" == "backend" ]; then
- echo "deployments=squid-testnet-v2 explorer meta-storage faucet" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.service }}" == "frontend" ]; then
- echo "deployments=frontend-nginx" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.service }}" == "faucet" ]; then
- echo "deployments=faucet" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.service }}" == "meta-storage" ]; then
- echo "deployments=meta-storage" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.service }}" == "explorer" ]; then
- echo "deployments=explorer" >> $GITHUB_OUTPUT
- elif [ "${{ github.event.inputs.service }}" == "squid" ]; then
- echo "deployments=squid-testnet-v2" >> $GITHUB_OUTPUT
- else
- echo "deployments=" >> $GITHUB_OUTPUT
- fi
- fi
-
+ echo "namespace=prod-idea" >> $GITHUB_OUTPUT
+ echo "deployments=frontend-temp" >> $GITHUB_OUTPUT
- name: Deploy to k8s
uses: sergeyfilyanin/kubectl-aws-eks@master
with:
diff --git a/idea/gear/frontend/Dockerfile b/idea/gear/frontend/Dockerfile
index 52bada34b..ae6f9d11d 100644
--- a/idea/gear/frontend/Dockerfile
+++ b/idea/gear/frontend/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:20-alpine AS builder
+FROM node:20-slim AS builder
WORKDIR /src
diff --git a/idea/gear/frontend/package.json b/idea/gear/frontend/package.json
index b785e3920..710bf533a 100644
--- a/idea/gear/frontend/package.json
+++ b/idea/gear/frontend/package.json
@@ -10,6 +10,7 @@
"clean": "echo \"clean gear-idea-frontend\" && rm -rf dist"
},
"dependencies": {
+ "@base-ui-components/react": "^1.0.0-beta.7",
"@gear-js/api": "0.44.2",
"@gear-js/react-hooks": "*",
"@gear-js/ui": "*",
diff --git a/idea/gear/frontend/src/features/code/ui/code-card/code-card.module.scss b/idea/gear/frontend/src/features/code/ui/code-card/code-card.module.scss
index 0167d8f68..ae7956427 100644
--- a/idea/gear/frontend/src/features/code/ui/code-card/code-card.module.scss
+++ b/idea/gear/frontend/src/features/code/ui/code-card/code-card.module.scss
@@ -1,76 +1,84 @@
-@use '@gear-js/ui/mixins' as *;
-@use '@/shared/assets/styles/shared' as *;
-@use '@/shared/assets/styles/mixins' as *;
-@use '@/shared/assets/styles/variables' as *;
-@use '@/shared/assets/styles/animations' as *;
+@use '@gear-js/ui/mixins' as *; '@gear-js/ui/mixins' as *; @use '@/共享/资产/样式/共享' as *; as *;
+@use '@/共享/资产/样式/共享'作为 *;'@/共享/资产/样式/共享'作为 *;
+@use '@/共享/资产/样式/混合'相对的
+@use '@/共享/资产/样式/变量'作为 *;
+@use '@/共享/资产/样式/动画'作为 *;
-.horizontalCodeCard {
- @include transition(background-color);
+。地平线ntalCodeCard {
+ @include过渡(background-color);
- display: flex;
- position: relative;
- border-radius: toRem(16);
- background-color: $bgColor4;
-
- &:hover {
- background-color: $gray100;
+展示: 弯曲;
+位置:相对的;
+徘徊边界半径(16);边界半径: 托雷姆(16);
+背景颜色:$bgColor4;背景颜色:$bgColor4;
- .content::after {
- background: radial-gradient(50% 50% at 50% 50%, rgba($successColor, 0.45) 25%, rgba(24, 24, 27, 0) 100%);
+&:悬停{hover {
+背景颜色: $gray100;
+ㄓ
+。内容::后 {
+背景: 径向渐变(50% 50%在50% 50%, RGBA($成功颜色,0.45) 25%, RGBA(24, 24, 27, 0) 100%);
}
}
}
-.content {
- flex: 1 1;
- padding: toRem(24);
- overflow: hidden;
- position: relative;
-
- &::after {
- content: '';
- position: absolute;
- right: -40%;
- bottom: -100%;
- width: 100%;
- height: 200%;
- background: radial-gradient(50% 50% at 50% 50%, rgba($successColor, 0.23) 0%, rgba(24, 24, 27, 0) 100%);
+。内容 {
+弯曲:1 1;
+填充: 托雷姆(24);
+溢出: 隐;
+位置: 相对的;
+
+&::后 {
+内容:'';'';'';'';
+位置: 绝对;
+正确的:-40%;-40%;-40%;-40%;
+底部:-100%;-100%;-100%;-100%;-100%;-100%;-100%;-100%;-100%;-100%;-100%;-100%;
+宽度:100%;100%;100%;100%;
+高度:200%;200%;200%;200%;
+背景: 径向渐变(50% 50% at 50% 50%, RGBA($successColor, 0.23) 0%, RGBA(24, 24, 27, 0) 100%);50% 50% at 50% 50%, RGBA($successColor, 0.23) 0%, RGBA(24, 24, 27, 0) 100%);50% 50% at 50% 50%, RGBA($successColor, 0.23) 0%, RGBA(24, 24, 27, 0) 100%);50% 50% at 50% 50%, RGBA($successColor, 0.23) 0%, RGBA(24, 24, 27, 0) 100%);
}
- .name {
- @include transition;
- display: block;
- max-width: 60%;
- overflow: hidden;
- text-overflow: ellipsis;
- font-size: $fontSizeBig;
- font-family: 'Kanit';
- font-weight: 600;
- line-height: 1.3;
- margin-right: $margin;
- margin-bottom: 10px;
- position: relative;
- z-index: 1;
-
- &:hover {
- opacity: 0.5;
+。关联 {
+@包括过渡;@includetransition;包括过渡;@include过渡;
+
+右边距: $margin;
+边距底部: 10像素;10像素;10像素;10像素;
+
+展示: 弯曲;
+对齐项目: 中心;
+差距:8像素;8像素;8像素;8像素;
+
+位置: 相对的;
+z 索引:1;1;1;1;
+
+&:徘徊 {
+不透明度:0.5;0.5;
}
}
+。姓名 {
+最大宽度:60%;60%;
+溢出: 隐;
+文本溢出: 省略;
+字体大小:$fontSizeBig;
+字体系列:'卡尼特';
+字体粗细:600;
+行高:1.3;
+ }
+
.otherInfo {
- display: flex;
- position: relative;
+展示: 弯曲;
+位置:相对;
}
- .codeId {
- z-index: 2;
- margin-bottom: 10px;
+.codeIdz 索引.codeId {
+z 索引:2;z 索引: 2;
+底部边距:10像素;边距底部:10像素;
}
- .otherInfo {
- @include childrenMargin($margin, right);
+.otherInfo {.otherInfo {
+@include儿童保证金($margin, 右);@include儿童保证金($margin, 右);
- z-index: 4;
+z 索引:4;z 索引:4;
}
}
diff --git a/idea/gear/frontend/src/features/code/ui/code-card/code-card.tsx b/idea/gear/frontend/src/features/code/ui/code-card/code-card.tsx
index 599576c74..fba403ef4 100644
--- a/idea/gear/frontend/src/features/code/ui/code-card/code-card.tsx
+++ b/idea/gear/frontend/src/features/code/ui/code-card/code-card.tsx
@@ -1,6 +1,7 @@
import { Link, generatePath } from 'react-router-dom';
import { LocalCode } from '@/features/local-indexer';
+import { isVftCode, VftTag } from '@/features/vft-standard';
import CreateProgramSVG from '@/shared/assets/images/actions/create-program.svg?react';
import RelatedrelatedProgramsSVG from '@/shared/assets/images/actions/related-programs.svg?react';
import { absoluteRoutes, routes } from '@/shared/config';
@@ -24,8 +25,9 @@ function CodeCard({ code }: Props) {
return (
-
- {name || 'Code'}
+
+
{name || 'Code'}
+ {isVftCode(code.id) &&
}
{'timestamp' in code && (
diff --git a/idea/gear/frontend/src/features/program/ui/program-card/program-card.module.scss b/idea/gear/frontend/src/features/program/ui/program-card/program-card.module.scss
index a0683b9cd..abc6359f2 100644
--- a/idea/gear/frontend/src/features/program/ui/program-card/program-card.module.scss
+++ b/idea/gear/frontend/src/features/program/ui/program-card/program-card.module.scss
@@ -39,6 +39,14 @@
.link {
@include transition;
+
+ margin-right: $margin;
+ margin-bottom: 10px;
+
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
position: relative;
z-index: 1;
@@ -77,8 +85,6 @@
font-family: 'Kanit';
font-weight: 600;
line-height: 1.3;
- margin-right: $margin;
- margin-bottom: 10px;
}
}
diff --git a/idea/gear/frontend/src/features/program/ui/program-card/program-card.tsx b/idea/gear/frontend/src/features/program/ui/program-card/program-card.tsx
index 902b19e7e..c49c7f6d0 100644
--- a/idea/gear/frontend/src/features/program/ui/program-card/program-card.tsx
+++ b/idea/gear/frontend/src/features/program/ui/program-card/program-card.tsx
@@ -2,6 +2,7 @@ import { clsx } from 'clsx';
import { Link, generatePath } from 'react-router-dom';
import { LocalProgram } from '@/features/local-indexer';
+import { isVftCode, VftTag } from '@/features/vft-standard';
import { IssueVoucher, VoucherBadge } from '@/features/voucher';
import sendSVG from '@/shared/assets/images/actions/send.svg?react';
import { absoluteRoutes } from '@/shared/config';
@@ -32,6 +33,7 @@ const ProgramCard = ({ program, vertical }: Props) => {
{name}
+ {program.codeId && isVftCode(program.codeId) &&
}
diff --git a/idea/gear/frontend/src/features/vft-standard/assets/extended_vft.idl b/idea/gear/frontend/src/features/vft-standard/assets/extended_vft.idl
new file mode 100644
index 000000000..c252e14ba
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/assets/extended_vft.idl
@@ -0,0 +1,48 @@
+constructor {
+ New : (name: str, symbol: str, decimals: u8);
+};
+
+service Vft {
+ Burn : (from: actor_id, value: u256) -> bool;
+ GrantAdminRole : (to: actor_id) -> null;
+ GrantBurnerRole : (to: actor_id) -> null;
+ GrantMinterRole : (to: actor_id) -> null;
+ Mint : (to: actor_id, value: u256) -> bool;
+ RevokeAdminRole : (from: actor_id) -> null;
+ RevokeBurnerRole : (from: actor_id) -> null;
+ RevokeMinterRole : (from: actor_id) -> null;
+ Approve : (spender: actor_id, value: u256) -> bool;
+ Transfer : (to: actor_id, value: u256) -> bool;
+ TransferFrom : (from: actor_id, to: actor_id, value: u256) -> bool;
+ query Admins : () -> vec actor_id;
+ query Burners : () -> vec actor_id;
+ query Minters : () -> vec actor_id;
+ query Allowance : (owner: actor_id, spender: actor_id) -> u256;
+ query BalanceOf : (account: actor_id) -> u256;
+ query Decimals : () -> u8;
+ query Name : () -> str;
+ query Symbol : () -> str;
+ query TotalSupply : () -> u256;
+
+ events {
+ Minted: struct {
+ to: actor_id,
+ value: u256,
+ };
+ Burned: struct {
+ from: actor_id,
+ value: u256,
+ };
+ Approval: struct {
+ owner: actor_id,
+ spender: actor_id,
+ value: u256,
+ };
+ Transfer: struct {
+ from: actor_id,
+ to: actor_id,
+ value: u256,
+ };
+ }
+};
+
diff --git a/idea/gear/frontend/src/features/vft-standard/consts.ts b/idea/gear/frontend/src/features/vft-standard/consts.ts
new file mode 100644
index 000000000..a6a3ebd46
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/consts.ts
@@ -0,0 +1,3 @@
+const VFT_CODE_IDS = ['0x17a3faf3760254f07b198559a01cf97d7cbf35d4b149293dcc1b8f1edac9e10b'];
+
+export { VFT_CODE_IDS };
diff --git a/idea/gear/frontend/src/features/vft-standard/hooks.ts b/idea/gear/frontend/src/features/vft-standard/hooks.ts
new file mode 100644
index 000000000..d32ed1fa4
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/hooks.ts
@@ -0,0 +1,37 @@
+import { HexString } from '@gear-js/api';
+import { useAccount, useProgram, useProgramQuery } from '@gear-js/react-hooks';
+
+import { SailsProgram } from './sails';
+
+function useVftProgram(id: HexString | undefined) {
+ return useProgram({ id, library: SailsProgram });
+}
+
+function useVftDecimals(id: HexString | undefined) {
+ const { data: program } = useVftProgram(id);
+
+ return useProgramQuery({ program, serviceName: 'vft', functionName: 'decimals', args: [] });
+}
+
+function useVftRoles(id: HexString | undefined) {
+ const { data: program } = useVftProgram(id);
+
+ const admins = useProgramQuery({ program, serviceName: 'vft', functionName: 'admins', args: [] });
+ const minters = useProgramQuery({ program, serviceName: 'vft', functionName: 'minters', args: [] });
+ const burners = useProgramQuery({ program, serviceName: 'vft', functionName: 'burners', args: [] });
+
+ return { admins, minters, burners };
+}
+
+function useAccountRole(id: HexString | undefined) {
+ const { account } = useAccount();
+ const { admins, minters, burners } = useVftRoles(id);
+
+ const isAdmin = account ? admins.data?.includes(account.decodedAddress) : false;
+ const isMinter = account ? minters.data?.includes(account.decodedAddress) : false;
+ const isBurner = account ? burners.data?.includes(account.decodedAddress) : false;
+
+ return { isAdmin, isMinter, isBurner };
+}
+
+export { useVftProgram, useVftDecimals, useVftRoles, useAccountRole };
diff --git a/idea/gear/frontend/src/features/vft-standard/index.ts b/idea/gear/frontend/src/features/vft-standard/index.ts
new file mode 100644
index 000000000..42cf43aae
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/index.ts
@@ -0,0 +1,4 @@
+import { VftTag, Vft } from './ui';
+import { isVftCode } from './utils';
+
+export { VftTag, isVftCode, Vft };
diff --git a/idea/gear/frontend/src/features/vft-standard/sails.ts b/idea/gear/frontend/src/features/vft-standard/sails.ts
new file mode 100644
index 000000000..cb6408048
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/sails.ts
@@ -0,0 +1,442 @@
+/* eslint-disable */
+
+import { GearApi, BaseGearProgram, HexString } from '@gear-js/api';
+import { TypeRegistry } from '@polkadot/types';
+import {
+ TransactionBuilder,
+ ActorId,
+ QueryBuilder,
+ getServiceNamePrefix,
+ getFnNamePrefix,
+ ZERO_ADDRESS,
+} from 'sails-js';
+
+export class SailsProgram {
+ public readonly registry: TypeRegistry;
+ public readonly vft: Vft;
+ private _program?: BaseGearProgram;
+
+ constructor(
+ public api: GearApi,
+ programId?: `0x${string}`,
+ ) {
+ const types: Record
= {};
+
+ this.registry = new TypeRegistry();
+ this.registry.setKnownTypes({ types });
+ this.registry.register(types);
+ if (programId) {
+ this._program = new BaseGearProgram(programId, api);
+ }
+
+ this.vft = new Vft(this);
+ }
+
+ public get programId(): `0x${string}` {
+ if (!this._program) throw new Error(`Program ID is not set`);
+ return this._program.id;
+ }
+
+ newCtorFromCode(
+ code: Uint8Array | Buffer | HexString,
+ name: string,
+ $symbol: string,
+ decimals: number,
+ ): TransactionBuilder {
+ const builder = new TransactionBuilder(
+ this.api,
+ this.registry,
+ 'upload_program',
+ null,
+ 'New',
+ [name, $symbol, decimals],
+ '(String, String, u8)',
+ 'String',
+ code,
+ async (programId) => {
+ this._program = await BaseGearProgram.new(programId, this.api);
+ },
+ );
+ return builder;
+ }
+
+ newCtorFromCodeId(codeId: `0x${string}`, name: string, $symbol: string, decimals: number) {
+ const builder = new TransactionBuilder(
+ this.api,
+ this.registry,
+ 'create_program',
+ null,
+ 'New',
+ [name, $symbol, decimals],
+ '(String, String, u8)',
+ 'String',
+ codeId,
+ async (programId) => {
+ this._program = await BaseGearProgram.new(programId, this.api);
+ },
+ );
+ return builder;
+ }
+}
+
+export class Vft {
+ constructor(private _program: SailsProgram) {}
+
+ public burn($from: ActorId, value: number | string | bigint): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'Burn',
+ [$from, value],
+ '([u8;32], U256)',
+ 'bool',
+ this._program.programId,
+ );
+ }
+
+ public grantAdminRole(to: ActorId): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'GrantAdminRole',
+ to,
+ '[u8;32]',
+ 'Null',
+ this._program.programId,
+ );
+ }
+
+ public grantBurnerRole(to: ActorId): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'GrantBurnerRole',
+ to,
+ '[u8;32]',
+ 'Null',
+ this._program.programId,
+ );
+ }
+
+ public grantMinterRole(to: ActorId): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'GrantMinterRole',
+ to,
+ '[u8;32]',
+ 'Null',
+ this._program.programId,
+ );
+ }
+
+ public mint(to: ActorId, value: number | string | bigint): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'Mint',
+ [to, value],
+ '([u8;32], U256)',
+ 'bool',
+ this._program.programId,
+ );
+ }
+
+ public revokeAdminRole($from: ActorId): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'RevokeAdminRole',
+ $from,
+ '[u8;32]',
+ 'Null',
+ this._program.programId,
+ );
+ }
+
+ public revokeBurnerRole($from: ActorId): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'RevokeBurnerRole',
+ $from,
+ '[u8;32]',
+ 'Null',
+ this._program.programId,
+ );
+ }
+
+ public revokeMinterRole($from: ActorId): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'RevokeMinterRole',
+ $from,
+ '[u8;32]',
+ 'Null',
+ this._program.programId,
+ );
+ }
+
+ public approve(spender: ActorId, value: number | string | bigint): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'Approve',
+ [spender, value],
+ '([u8;32], U256)',
+ 'bool',
+ this._program.programId,
+ );
+ }
+
+ public transfer(to: ActorId, value: number | string | bigint): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'Transfer',
+ [to, value],
+ '([u8;32], U256)',
+ 'bool',
+ this._program.programId,
+ );
+ }
+
+ public transferFrom($from: ActorId, to: ActorId, value: number | string | bigint): TransactionBuilder {
+ if (!this._program.programId) throw new Error('Program ID is not set');
+ return new TransactionBuilder(
+ this._program.api,
+ this._program.registry,
+ 'send_message',
+ 'Vft',
+ 'TransferFrom',
+ [$from, to, value],
+ '([u8;32], [u8;32], U256)',
+ 'bool',
+ this._program.programId,
+ );
+ }
+
+ public admins(): QueryBuilder> {
+ return new QueryBuilder>(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Admins',
+ null,
+ null,
+ 'Vec<[u8;32]>',
+ );
+ }
+
+ public burners(): QueryBuilder> {
+ return new QueryBuilder>(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Burners',
+ null,
+ null,
+ 'Vec<[u8;32]>',
+ );
+ }
+
+ public minters(): QueryBuilder> {
+ return new QueryBuilder>(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Minters',
+ null,
+ null,
+ 'Vec<[u8;32]>',
+ );
+ }
+
+ public allowance(owner: ActorId, spender: ActorId): QueryBuilder {
+ return new QueryBuilder(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Allowance',
+ [owner, spender],
+ '([u8;32], [u8;32])',
+ 'U256',
+ );
+ }
+
+ public balanceOf(account: ActorId): QueryBuilder {
+ return new QueryBuilder(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'BalanceOf',
+ account,
+ '[u8;32]',
+ 'U256',
+ );
+ }
+
+ public decimals(): QueryBuilder {
+ return new QueryBuilder(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Decimals',
+ null,
+ null,
+ 'u8',
+ );
+ }
+
+ public name(): QueryBuilder {
+ return new QueryBuilder(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Name',
+ null,
+ null,
+ 'String',
+ );
+ }
+
+ public symbol(): QueryBuilder {
+ return new QueryBuilder(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'Symbol',
+ null,
+ null,
+ 'String',
+ );
+ }
+
+ public totalSupply(): QueryBuilder {
+ return new QueryBuilder(
+ this._program.api,
+ this._program.registry,
+ this._program.programId,
+ 'Vft',
+ 'TotalSupply',
+ null,
+ null,
+ 'U256',
+ );
+ }
+
+ public subscribeToMintedEvent(
+ callback: (data: { to: ActorId; value: number | string | bigint }) => void | Promise,
+ ): Promise<() => void> {
+ return this._program.api.gearEvents.subscribeToGearEvent('UserMessageSent', ({ data: { message } }) => {
+ if (!message.source.eq(this._program.programId) || !message.destination.eq(ZERO_ADDRESS)) {
+ return;
+ }
+
+ const payload = message.payload.toHex();
+ if (getServiceNamePrefix(payload) === 'Vft' && getFnNamePrefix(payload) === 'Minted') {
+ callback(
+ this._program.registry
+ .createType('(String, String, {"to":"[u8;32]","value":"U256"})', message.payload)[2]
+ .toJSON() as unknown as { to: ActorId; value: number | string | bigint },
+ );
+ }
+ });
+ }
+
+ public subscribeToBurnedEvent(
+ callback: (data: { from: ActorId; value: number | string | bigint }) => void | Promise,
+ ): Promise<() => void> {
+ return this._program.api.gearEvents.subscribeToGearEvent('UserMessageSent', ({ data: { message } }) => {
+ if (!message.source.eq(this._program.programId) || !message.destination.eq(ZERO_ADDRESS)) {
+ return;
+ }
+
+ const payload = message.payload.toHex();
+ if (getServiceNamePrefix(payload) === 'Vft' && getFnNamePrefix(payload) === 'Burned') {
+ callback(
+ this._program.registry
+ .createType('(String, String, {"from":"[u8;32]","value":"U256"})', message.payload)[2]
+ .toJSON() as unknown as { from: ActorId; value: number | string | bigint },
+ );
+ }
+ });
+ }
+
+ public subscribeToApprovalEvent(
+ callback: (data: { owner: ActorId; spender: ActorId; value: number | string | bigint }) => void | Promise,
+ ): Promise<() => void> {
+ return this._program.api.gearEvents.subscribeToGearEvent('UserMessageSent', ({ data: { message } }) => {
+ if (!message.source.eq(this._program.programId) || !message.destination.eq(ZERO_ADDRESS)) {
+ return;
+ }
+
+ const payload = message.payload.toHex();
+ if (getServiceNamePrefix(payload) === 'Vft' && getFnNamePrefix(payload) === 'Approval') {
+ callback(
+ this._program.registry
+ .createType('(String, String, {"owner":"[u8;32]","spender":"[u8;32]","value":"U256"})', message.payload)[2]
+ .toJSON() as unknown as { owner: ActorId; spender: ActorId; value: number | string | bigint },
+ );
+ }
+ });
+ }
+
+ public subscribeToTransferEvent(
+ callback: (data: { from: ActorId; to: ActorId; value: number | string | bigint }) => void | Promise,
+ ): Promise<() => void> {
+ return this._program.api.gearEvents.subscribeToGearEvent('UserMessageSent', ({ data: { message } }) => {
+ if (!message.source.eq(this._program.programId) || !message.destination.eq(ZERO_ADDRESS)) {
+ return;
+ }
+
+ const payload = message.payload.toHex();
+ if (getServiceNamePrefix(payload) === 'Vft' && getFnNamePrefix(payload) === 'Transfer') {
+ callback(
+ this._program.registry
+ .createType('(String, String, {"from":"[u8;32]","to":"[u8;32]","value":"U256"})', message.payload)[2]
+ .toJSON() as unknown as { from: ActorId; to: ActorId; value: number | string | bigint },
+ );
+ }
+ });
+ }
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/index.ts b/idea/gear/frontend/src/features/vft-standard/ui/index.ts
new file mode 100644
index 000000000..f0873b7fe
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/index.ts
@@ -0,0 +1,4 @@
+import { Vft } from './vft';
+import { VftTag } from './vft-tag';
+
+export { Vft, VftTag };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/index.ts b/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/index.ts
new file mode 100644
index 000000000..bbd0f71a7
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/index.ts
@@ -0,0 +1,3 @@
+import { VftTag } from './vft-tag';
+
+export { VftTag };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/vft-tag.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/vft-tag.module.scss
new file mode 100644
index 000000000..89042e213
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/vft-tag.module.scss
@@ -0,0 +1,21 @@
+.tag {
+ text-align: center;
+
+ font-weight: 500;
+ color: rgba(#fff, 0.6);
+
+ border: 1px solid rgba(#fff, 0.6);
+ border-radius: 4px;
+
+ &.small {
+ padding: 0 4px;
+
+ font-size: 10px;
+ }
+
+ &.medium {
+ padding: 0 6px;
+
+ font-size: 12px;
+ }
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/vft-tag.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/vft-tag.tsx
new file mode 100644
index 000000000..df1f607b6
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft-tag/vft-tag.tsx
@@ -0,0 +1,13 @@
+import { cx } from '@/shared/helpers';
+
+import styles from './vft-tag.module.scss';
+
+type Props = {
+ size?: 'medium' | 'small';
+};
+
+function VftTag({ size = 'small' }: Props) {
+ return VFT;
+}
+
+export { VftTag };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/index.ts b/idea/gear/frontend/src/features/vft-standard/ui/vft/index.ts
new file mode 100644
index 000000000..7f9da2146
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/index.ts
@@ -0,0 +1,3 @@
+import { Vft } from './vft';
+
+export { Vft };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.module.scss
new file mode 100644
index 000000000..d34e87914
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.module.scss
@@ -0,0 +1,59 @@
+@use '@/shared/assets/styles/variables' as *;
+
+.accountOverview {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.title {
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: $textColor;
+ margin: 0;
+}
+
+.info {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.infoItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.label {
+ font-size: 14px;
+ color: $gray700;
+}
+
+.value {
+ font-size: 14px;
+ font-weight: 500;
+ color: $textColor;
+}
+
+.roles {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.roleBadge {
+ padding: 2px 8px;
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba($successColor, 1);
+ background-color: rgba($successColor, 0.1);
+ border: 1px solid rgba($successColor, 0.2);
+ border-radius: 4px;
+}
+
+.noRoles {
+ font-size: 14px;
+ color: $gray600;
+}
\ No newline at end of file
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.tsx
new file mode 100644
index 000000000..03bd32259
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.tsx
@@ -0,0 +1,73 @@
+import { HexString } from '@gear-js/api';
+import { useAccount, useProgramQuery } from '@gear-js/react-hooks';
+import { ZERO_ADDRESS } from 'sails-js';
+
+import { Box } from '@/shared/ui';
+
+import { useAccountRole, useVftProgram } from '../../hooks';
+
+import styles from './vft-account-overview.module.scss';
+
+type Props = {
+ id: HexString | undefined;
+};
+
+function VftAccountOverview({ id }: Props) {
+ const { account } = useAccount();
+ const { data: program } = useVftProgram(id);
+
+ const balance = useProgramQuery({
+ program,
+ serviceName: 'vft',
+ functionName: 'balanceOf',
+ args: [account?.decodedAddress || ZERO_ADDRESS],
+ query: { enabled: Boolean(account) },
+ });
+
+ const { isAdmin, isMinter, isBurner } = useAccountRole(id);
+
+ const renderRoles = () => {
+ const roles = [];
+
+ if (isAdmin)
+ roles.push(
+
+ Admin
+ ,
+ );
+ if (isMinter)
+ roles.push(
+
+ Minter
+ ,
+ );
+ if (isBurner)
+ roles.push(
+
+ Burner
+ ,
+ );
+
+ return roles.length > 0 ? roles : No roles;
+ };
+
+ return (
+
+ Account Overview
+
+
+
+ Your Balance:
+ {balance.data || '0'}
+
+
+
+
Your Roles:
+
{renderRoles()}
+
+
+
+ );
+}
+
+export { VftAccountOverview };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.tsx.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.tsx.module.scss
new file mode 100644
index 000000000..c0c54a023
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-account-overview.tsx.module.scss
@@ -0,0 +1,59 @@
+@use '@/shared/assets/styles/variables' as *;
+
+.accountOverview {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.title {
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: $textColor;
+ margin: 0;
+}
+
+.info {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.infoItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.label {
+ font-size: 14px;
+ color: $gray700;
+}
+
+.value {
+ font-size: 14px;
+ font-weight: 500;
+ color: $textColor;
+}
+
+.roles {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.roleBadge {
+ padding: 2px 8px;
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba($successColor, 1);
+ background-color: rgba($successColor, 0.1);
+ border: 1px solid rgba($successColor, 0.2);
+ border-radius: 4px;
+}
+
+.noRoles {
+ font-size: 14px;
+ color: $gray600;
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-actions.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-actions.module.scss
new file mode 100644
index 000000000..7badc4c8b
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-actions.module.scss
@@ -0,0 +1,168 @@
+@use '@/shared/assets/styles/variables' as *;
+
+.Button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 12px;
+ margin: 0;
+ outline: 0;
+ border: 1px solid $gray100;
+ border-radius: 8px;
+ background-color: $bgColor5;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1;
+ color: $textColor;
+ user-select: none;
+ cursor: pointer;
+ transition: all 0.25s;
+
+ &:hover {
+ background-color: $bgColorTertiary;
+ }
+
+ &:active {
+ background-color: $bgColorTertiary;
+ }
+
+ &[data-popup-open] {
+ background-color: $bgColorTertiary;
+ }
+
+ &:focus-visible {
+ outline: 2px solid $successColor;
+ outline-offset: -1px;
+ }
+}
+
+.ButtonIcon {
+ width: 10px;
+ height: 10px;
+
+ path {
+ stroke: $gray700;
+ }
+}
+
+.Positioner {
+ outline: 0;
+ z-index: 10;
+}
+
+.Popup {
+ min-width: 180px;
+ padding: 4px;
+ border-radius: 12px;
+ background-color: $bgColor5;
+ box-shadow:
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.05),
+ 0 20px 76px 0 rgba(0, 0, 0, 0.5);
+ transform-origin: var(--transform-origin);
+ transition:
+ transform 150ms,
+ opacity 150ms;
+
+ &[data-starting-style],
+ &[data-ending-style] {
+ opacity: 0;
+ transform: scale(0.9);
+ }
+
+ @media (prefers-color-scheme: light) {
+ outline: 1px solid var(--color-gray-200);
+ box-shadow:
+ 0 10px 15px -3px var(--color-gray-200),
+ 0 4px 6px -4px var(--color-gray-200);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ outline: 1px solid var(--color-gray-300);
+ outline-offset: -1px;
+ }
+}
+
+.Arrow {
+ display: flex;
+
+ &[data-side='top'] {
+ bottom: -8px;
+ rotate: 180deg;
+ }
+
+ &[data-side='bottom'] {
+ top: -8px;
+ rotate: 0deg;
+ }
+
+ &[data-side='left'] {
+ right: -13px;
+ rotate: 90deg;
+ }
+
+ &[data-side='right'] {
+ left: -13px;
+ rotate: -90deg;
+ }
+}
+
+.ArrowFill {
+ fill: canvas;
+}
+
+.ArrowOuterStroke {
+ @media (prefers-color-scheme: light) {
+ fill: black;
+ }
+}
+
+.ArrowInnerStroke {
+ @media (prefers-color-scheme: dark) {
+ fill: black;
+ }
+}
+
+.Item {
+ outline: 0;
+ cursor: default;
+ user-select: none;
+ padding-block: 0.5rem;
+ padding-left: 1rem;
+ padding-right: 2rem;
+ display: flex;
+ font-size: 0.875rem;
+ line-height: 1rem;
+ background-color: transparent;
+ color: var(--color-gray-50);
+
+ &[data-highlighted] {
+ z-index: 0;
+ position: relative;
+ color: #000;
+ }
+
+ &[data-highlighted]::before {
+ content: '';
+ z-index: -1;
+ position: absolute;
+ inset-block: 0;
+ inset-inline: 0.25rem;
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-900);
+ }
+}
+
+.Separator {
+ margin: 0.375rem 1rem;
+ height: 1px;
+ background-color: var(--color-gray-200);
+}
+
+.modal {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-actions.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-actions.tsx
new file mode 100644
index 000000000..ee0bd9294
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-actions.tsx
@@ -0,0 +1,123 @@
+import { Menu } from '@base-ui-components/react/menu';
+import { Button, Input, Modal } from '@gear-js/ui';
+import * as React from 'react';
+import { useState } from 'react';
+
+import styles from './vft-actions.module.scss';
+
+const ACTIONS = [
+ { label: 'Burn' },
+ { label: 'Mint' },
+ { label: 'Transfer' },
+ { label: 'Transfer From' },
+ { label: 'Approve' },
+];
+
+const READS = [{ label: 'Balance Of' }, { label: 'Allowance' }];
+
+type MenuProps = {
+ triggerText: string;
+ items: { label: string; onClick: () => void }[];
+};
+
+function ExampleMenu({ triggerText, items }: MenuProps) {
+ const renderItems = () =>
+ items.map((item) => (
+ } onClick={item.onClick}>
+ {item.label}
+
+ ));
+
+ return (
+
+
+ {triggerText}
+
+
+
+
+
+
+
+
+
+ {renderItems()}
+
+
+
+
+ );
+}
+
+function ArrowSvg(props: React.ComponentProps<'svg'>) {
+ return (
+
+ );
+}
+
+function ChevronDownIcon(props: React.ComponentProps<'svg'>) {
+ return (
+
+ );
+}
+
+function PlaceholderModal({ close }: { close: () => void }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function VftActions() {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ return (
+ <>
+ ({ label: action.label, onClick: () => setIsModalOpen(true) }))}
+ />
+
+ {isModalOpen && setIsModalOpen(false)} />}
+ >
+ );
+}
+
+function VftReads() {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ return (
+ <>
+ ({ label: read.label, onClick: () => setIsModalOpen(true) }))}
+ />
+
+ {isModalOpen && setIsModalOpen(false)} />}
+ >
+ );
+}
+
+export { VftActions, VftReads };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-events.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-events.module.scss
new file mode 100644
index 000000000..633ea8726
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-events.module.scss
@@ -0,0 +1,118 @@
+@use '@gear-js/ui/headings' as *;
+@use '@/shared/assets/styles/variables' as *;
+
+.container {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 24px;
+}
+
+.eventCard {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-height: 500px;
+ overflow: hidden;
+}
+
+.eventTitle {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: $textColor;
+ margin: 0;
+}
+
+.eventList {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ overflow-y: auto;
+ max-height: 420px;
+}
+
+.eventItem {
+ padding: 12px;
+ background-color: rgba(255, 255, 255, 0.02);
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ transition: all 0.25s;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.08);
+ }
+}
+
+.eventHeader {
+ display: flex;
+ justify-content: space-between;
+ gap: 4px;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.eventName {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: $textColor;
+ margin: 0;
+}
+
+.eventMeta {
+ display: flex;
+ gap: 8px;
+}
+
+.timestamp {
+ font-size: 12px;
+ color: $gray600;
+}
+
+.blockNumber {
+ font-size: 11px;
+ color: $gray600;
+}
+
+.eventPayload {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 16px;
+}
+
+.payloadRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.payloadLabel {
+ font-size: 12px;
+ color: $gray700;
+ flex-shrink: 0;
+}
+
+.payloadValue {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ font-size: 12px;
+ font-weight: 500;
+ color: $textColor;
+ word-break: break-all;
+ text-align: right;
+}
+
+.emptyState {
+ padding: 24px;
+ text-align: center;
+ font-size: 14px;
+ color: $gray600;
+ font-style: italic;
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-events.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-events.tsx
new file mode 100644
index 000000000..36f7c1d7c
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-events.tsx
@@ -0,0 +1,239 @@
+import { HexString } from '@gear-js/api';
+import { Identicon } from '@polkadot/react-identicon';
+import { JSX } from 'react';
+import { Sails } from 'sails-js';
+
+import { EventType, useEvents } from '@/features/sails/api';
+import { getShortName, isUndefined } from '@/shared/helpers';
+import { Box } from '@/shared/ui';
+
+import { useVftDecimals } from '../../hooks';
+import { Vft } from '../../sails';
+
+import styles from './vft-events.module.scss';
+
+function formatUnits(value: bigint | string, decimals: number): string {
+ const bigIntValue = typeof value === 'string' ? BigInt(value) : value;
+
+ if (decimals === 0) {
+ return bigIntValue.toString();
+ }
+
+ const divisor = 10n ** BigInt(decimals);
+ const integerPart = bigIntValue / divisor;
+ const fractionalPart = bigIntValue % divisor;
+
+ if (fractionalPart === 0n) {
+ return integerPart.toString();
+ }
+
+ const fractionalStr = fractionalPart.toString().padStart(decimals, '0');
+ const trimmedFractional = fractionalStr.replace(/0+$/, '');
+
+ return `${integerPart}.${trimmedFractional}`;
+}
+
+type Props = {
+ id: HexString | undefined;
+ sails: Sails | undefined;
+};
+
+type TransferPayload = Parameters[0]>[0];
+type MintPayload = Parameters[0]>[0];
+type BurnPayload = Parameters[0]>[0];
+type ApprovePayload = Parameters[0]>[0];
+
+type EventListProps = {
+ title: string;
+ events: EventType[] | undefined;
+ eventName: string;
+ sails: Sails;
+ renderPayload: (payload: T) => JSX.Element;
+};
+
+function EventList({ title, events, eventName, sails, renderPayload }: EventListProps) {
+ if (!events) return null;
+
+ const getDecodedPayload = (payload: HexString) => {
+ return sails.services['Vft'].events[eventName].decode(payload) as T;
+ };
+
+ const renderEvents = () => {
+ if (events.length === 0) {
+ return No events yet;
+ }
+
+ return events.map(({ payload, ...event }) => {
+ const decodedPayload = getDecodedPayload(payload!);
+
+ return (
+
+
+
+ {renderPayload(decodedPayload)}
+
+ );
+ });
+ };
+
+ return (
+
+ {title}
+
+
+ );
+}
+
+function VftEvents({ id, sails }: Props) {
+ const { data: decimals } = useVftDecimals(id);
+
+ const transfers = useEvents({ service: 'vft', name: 'transfer', source: id });
+ const mints = useEvents({ service: 'vft', name: 'mint', source: id });
+ const burns = useEvents({ service: 'vft', name: 'burn', source: id });
+ const approvals = useEvents({ service: 'vft', name: 'approval', source: id });
+
+ if (!sails || isUndefined(decimals)) return;
+
+ const renderTransferPayload = (payload: TransferPayload) => {
+ return (
+ <>
+
+ From:
+
+
+
+ {getShortName(payload.from, 12)}
+
+
+
+
+ To:
+
+
+
+ {getShortName(payload.to, 12)}
+
+
+
+
+ Value:
+ {formatUnits(BigInt(payload.value), decimals)}
+
+ >
+ );
+ };
+
+ const renderApprovePayload = (payload: ApprovePayload) => {
+ return (
+ <>
+
+ Owner:
+
+
+
+ {getShortName(payload.owner, 12)}
+
+
+
+
+ Spender:
+
+
+
+ {getShortName(payload.spender, 12)}
+
+
+
+
+ Value:
+ {formatUnits(BigInt(payload.value), decimals)}
+
+ >
+ );
+ };
+
+ const renderMintPayload = (payload: MintPayload) => {
+ return (
+ <>
+
+ To:
+
+
+
+ {getShortName(payload.to, 12)}
+
+
+
+
+ Value:
+ {formatUnits(BigInt(payload.value), decimals)}
+
+ >
+ );
+ };
+
+ const renderBurnPayload = (payload: BurnPayload) => {
+ return (
+ <>
+
+ From:
+
+
+
+ {getShortName(payload.from, 12)}
+
+
+
+
+ Value:
+ {formatUnits(BigInt(payload.value), decimals)}
+
+ >
+ );
+ };
+
+ return (
+
+
+ title="Transfers"
+ events={transfers.data?.result}
+ eventName="Transfer"
+ sails={sails}
+ renderPayload={renderTransferPayload}
+ />
+
+
+ title="Approvals"
+ events={approvals.data?.result}
+ eventName="Approval"
+ sails={sails}
+ renderPayload={renderApprovePayload}
+ />
+
+
+ title="Mints"
+ events={mints.data?.result}
+ eventName="Minted"
+ sails={sails}
+ renderPayload={renderMintPayload}
+ />
+
+
+ title="Burns"
+ events={burns.data?.result}
+ eventName="Burned"
+ sails={sails}
+ renderPayload={renderBurnPayload}
+ />
+
+ );
+}
+
+export { VftEvents };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-overview.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-overview.module.scss
new file mode 100644
index 000000000..edd0c49a2
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-overview.module.scss
@@ -0,0 +1,68 @@
+@use '@/shared/assets/styles/variables' as *;
+
+.overview {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.titleRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+}
+
+.title {
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: $textColor;
+ margin: 0;
+}
+
+.symbol {
+ padding: 2px 8px;
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba($successColor, 1);
+ background-color: rgba($successColor, 0.1);
+ border: 1px solid rgba($successColor, 0.2);
+ border-radius: 4px;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.info {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.infoItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.label {
+ font-size: 14px;
+ color: $gray700;
+}
+
+.value {
+ font-size: 14px;
+ font-weight: 500;
+ color: $textColor;
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-overview.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-overview.tsx
new file mode 100644
index 000000000..0c852806b
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-overview.tsx
@@ -0,0 +1,52 @@
+import { HexString } from '@gear-js/api';
+import { useProgramQuery } from '@gear-js/react-hooks';
+
+import { Box } from '@/shared/ui';
+
+import { useVftProgram } from '../../hooks';
+
+import { VftActions, VftReads } from './vft-actions';
+import styles from './vft-overview.module.scss';
+
+type Props = {
+ id: HexString | undefined;
+};
+
+function VftOverview({ id }: Props) {
+ const { data: program } = useVftProgram(id);
+
+ const name = useProgramQuery({ program, serviceName: 'vft', functionName: 'name', args: [] });
+ const symbol = useProgramQuery({ program, serviceName: 'vft', functionName: 'symbol', args: [] });
+ const totalSupply = useProgramQuery({ program, serviceName: 'vft', functionName: 'totalSupply', args: [] });
+ const decimals = useProgramQuery({ program, serviceName: 'vft', functionName: 'decimals', args: [] });
+
+ return (
+
+
+
+
+
+ Total Supply:
+ {totalSupply.data}
+
+
+
+ Decimals:
+ {decimals.data}
+
+
+
+ );
+}
+
+export { VftOverview };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-roles.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-roles.module.scss
new file mode 100644
index 000000000..dd47767a1
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-roles.module.scss
@@ -0,0 +1,72 @@
+@use '@gear-js/ui/headings' as *;
+@use '@/shared/assets/styles/variables' as *;
+
+.container {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 24px;
+}
+
+.roleCard {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.roleTitle {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 1.3;
+ color: $textColor;
+ margin: 0;
+}
+
+.addressList {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.addressItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ padding: 8px 12px;
+ background-color: rgba(255, 255, 255, 0.02);
+ border-radius: 8px;
+ transition: background-color 0.25s;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.04);
+ }
+}
+
+.input {
+ margin-bottom: 24px;
+}
+
+.revokeButton {
+ margin-left: auto;
+}
+
+.grantButton {
+ padding: 4px 8px;
+
+ margin-top: auto;
+}
+
+.address {
+ font-size: 14px;
+ color: $gray700;
+}
+
+.emptyState {
+ padding: 8px 12px;
+ font-size: 14px;
+ color: $gray600;
+ font-style: italic;
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-roles.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-roles.tsx
new file mode 100644
index 000000000..f78b120db
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft-roles.tsx
@@ -0,0 +1,118 @@
+import { HexString } from '@gear-js/api';
+import { Button, Input, Modal } from '@gear-js/ui';
+import { Identicon } from '@polkadot/react-identicon';
+import { useState } from 'react';
+
+import PlusSVG from '@/shared/assets/images/actions/plus.svg?react';
+import RemoveSVG from '@/shared/assets/images/actions/remove.svg?react';
+import { getShortName } from '@/shared/helpers';
+import { Box, ConfirmModal } from '@/shared/ui';
+
+import { useAccountRole, useVftRoles } from '../../hooks';
+
+import styles from './vft-roles.module.scss';
+
+type Props = {
+ id: HexString | undefined;
+};
+
+function GrantRoleModal({ close }: { close: () => void }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function VftRoles({ id }: Props) {
+ const { admins, minters, burners } = useVftRoles(id);
+ const { isAdmin } = useAccountRole(id);
+
+ const [isGrantRoleModalOpen, setIsGrantRoleModalOpen] = useState(false);
+ const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
+
+ const renderRoles = (addresses: HexString[]) => {
+ if (!addresses || addresses.length === 0) {
+ return No addresses;
+ }
+
+ return addresses.map((address) => (
+
+
+ {getShortName(address, 12)}
+
+ {isAdmin && (
+
+ ));
+ };
+
+ return (
+ <>
+
+
+ Admins
+ {renderRoles(admins.data || [])}
+
+ {isAdmin && (
+
+
+
+ Minters
+ {renderRoles(minters.data || [])}
+
+ {isAdmin && (
+
+
+
+ Burners
+ {renderRoles(burners.data || [])}
+
+ {isAdmin && (
+
+
+
+ {isConfirmModalOpen && (
+ setIsConfirmModalOpen(false)}
+ onSubmit={() => {}}
+ />
+ )}
+
+ {isGrantRoleModalOpen && setIsGrantRoleModalOpen(false)} />}
+ >
+ );
+}
+
+export { VftRoles };
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft.module.scss b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft.module.scss
new file mode 100644
index 000000000..d35cb7f19
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft.module.scss
@@ -0,0 +1,13 @@
+@use '@/shared/assets/styles/variables' as *;
+
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+}
+
+.header {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 32px;
+}
diff --git a/idea/gear/frontend/src/features/vft-standard/ui/vft/vft.tsx b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft.tsx
new file mode 100644
index 000000000..9adbb133c
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/ui/vft/vft.tsx
@@ -0,0 +1,29 @@
+import { HexString } from '@gear-js/api';
+import { Sails } from 'sails-js';
+
+import { VftAccountOverview } from './vft-account-overview';
+import { VftEvents } from './vft-events';
+import { VftOverview } from './vft-overview';
+import { VftRoles } from './vft-roles';
+import styles from './vft.module.scss';
+
+type Props = {
+ id: HexString | undefined;
+ sails: Sails | undefined;
+};
+
+function Vft({ id, sails }: Props) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export { Vft };
diff --git a/idea/gear/frontend/src/features/vft-standard/utils.ts b/idea/gear/frontend/src/features/vft-standard/utils.ts
new file mode 100644
index 000000000..0e2090c68
--- /dev/null
+++ b/idea/gear/frontend/src/features/vft-standard/utils.ts
@@ -0,0 +1,7 @@
+import { HexString } from '@gear-js/api';
+
+import { VFT_CODE_IDS } from './consts';
+
+const isVftCode = (codeId: HexString) => VFT_CODE_IDS.includes(codeId);
+
+export { isVftCode };
diff --git a/idea/gear/frontend/src/pages/program/program.tsx b/idea/gear/frontend/src/pages/program/program.tsx
index 176397325..48400e2a3 100644
--- a/idea/gear/frontend/src/pages/program/program.tsx
+++ b/idea/gear/frontend/src/pages/program/program.tsx
@@ -9,6 +9,7 @@ import { ProgramMessages } from '@/features/message';
import { useMetadata, MetadataTable, isState } from '@/features/metadata';
import { ProgramStatus, ProgramTable, useProgram } from '@/features/program';
import { ProgramEvents, SailsPreview, useSails } from '@/features/sails';
+import { isVftCode, Vft, VftTag } from '@/features/vft-standard';
import { ProgramVouchers } from '@/features/voucher';
import { useModal } from '@/hooks';
import AddMetaSVG from '@/shared/assets/images/actions/addMeta.svg?react';
@@ -21,6 +22,7 @@ import { Box, UILink } from '@/shared/ui';
import styles from './program.module.scss';
const TABS = ['Metadata/Sails', 'Messages', 'Events', 'Vouchers'];
+const VFT_TABS = ['VFT Studio', 'Metadata/Sails', 'Messages', 'Events', 'Vouchers'];
type Params = {
programId: HexString;
@@ -35,6 +37,7 @@ const Program = () => {
const { sails, isLoading: isSailsLoading, refetch: refetchSails } = useSails(program?.codeId);
// commented out till code verified is fixed
// const { data: isCodeVerified } = useIsCodeVerified(program?.codeId);
+ const isVft = program?.codeId ? isVftCode(program.codeId) : false;
const isLoading = !isMetadataReady || isSailsLoading;
const isAnyQuery = sails ? Object.values(sails.services).some(({ queries }) => isAnyKey(queries)) : false;
@@ -67,8 +70,9 @@ const Program = () => {
});
};
- const renderTabs = () =>
- TABS.map((tab, index) => (
+ const renderTabs = () => {
+ const tabs = isVft ? VFT_TABS : TABS;
+ return tabs.map((tab, index) => (
));
+ };
return (