@meddleware/access-gate-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -0
- package/README.md +65 -0
- package/index.html +13 -0
- package/package.json +46 -0
- package/src/App.vue +160 -0
- package/src/components/AirdropForm.vue +75 -0
- package/src/components/CreateGateForm.vue +152 -0
- package/src/components/FreezeGateButton.vue +82 -0
- package/src/components/GateCard.vue +113 -0
- package/src/components/GateList.vue +41 -0
- package/src/components/GateSettingsPanel.vue +159 -0
- package/src/config.ts +21 -0
- package/src/constants.ts +32 -0
- package/src/env.d.ts +7 -0
- package/src/gates.ts +42 -0
- package/src/main.ts +6 -0
- package/src/wallet.ts +138 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MeddleWare
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
9
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
10
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
11
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
12
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
13
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
14
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @meddleware/access-gate-ui
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
|
|
5
|
+
A standalone Vue 3 operator console for on-chain **access gates** on Sui. Any third party can
|
|
6
|
+
create a gate, sell NFT-gated access to a resource (an upload relay, an API, a website, a game),
|
|
7
|
+
and manage it — all client-side, signing with their own wallet.
|
|
8
|
+
|
|
9
|
+
Gates are created under Meddleware's published `access_gate` package, so a 20 bps commission on
|
|
10
|
+
every purchase is structurally routed to the Meddleware treasury on-chain (see
|
|
11
|
+
[access-gate-sui](https://github.com/meddleware-org/access-gate-sui)).
|
|
12
|
+
|
|
13
|
+
Served at `sui-access-gate.meddleware.co.uk`. Later it will also be embedded in the main
|
|
14
|
+
Meddleware dashboard alongside the other Sui tools.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Connect any Sui wallet (wallet-standard)
|
|
19
|
+
- **Create a gate** — price, uses (unlimited pass or single-use), soulbound, auto-burn, and NFT metadata
|
|
20
|
+
- **My gates** — discover every gate the connected wallet administers (via its owned `AdminCap`s)
|
|
21
|
+
- **Manage** — update price, payment recipient, default uses, soulbound, auto-burn, and NFT metadata; pause/unpause purchases
|
|
22
|
+
- **Airdrop** — grant access NFTs to any address for free
|
|
23
|
+
- **Freeze** — make a gate immutable (irreversible, typed confirmation)
|
|
24
|
+
|
|
25
|
+
## Local development
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm run dev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> `@meddleware/nft-gate-client` resolves from the npm registry. To develop against an unpublished
|
|
33
|
+
> local copy, use `npm link @meddleware/nft-gate-client` or an `overrides` entry.
|
|
34
|
+
|
|
35
|
+
## Environment variables
|
|
36
|
+
|
|
37
|
+
All `VITE_*` vars are baked into the static bundle at build time.
|
|
38
|
+
|
|
39
|
+
| Variable | Default | Description |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `VITE_NETWORK` | `testnet` | `testnet` or `mainnet` |
|
|
42
|
+
| `VITE_RPC_TESTNET` | `https://sui-testnet-rpc.publicnode.com` | Sui JSON-RPC for testnet |
|
|
43
|
+
| `VITE_RPC_MAINNET` | `https://fullnode.mainnet.sui.io:443` | Sui JSON-RPC for mainnet |
|
|
44
|
+
|
|
45
|
+
The `access_gate` package ID and `PlatformConfig` object ID are **hardcoded** in
|
|
46
|
+
`src/constants.ts` (commission enforcement) and are not configurable.
|
|
47
|
+
|
|
48
|
+
## Docker build
|
|
49
|
+
|
|
50
|
+
The Docker context is this repo root; `@meddleware/nft-gate-client` resolves from npm, so it must
|
|
51
|
+
be published first.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
docker build --build-arg VITE_NETWORK=testnet -t access-gate-ui:latest .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Architecture
|
|
58
|
+
|
|
59
|
+
Thin app — no accounting logic. Every PTB (create/setters/airdrop/freeze) and every on-chain read
|
|
60
|
+
comes from `@meddleware/nft-gate-client`; the app only wires forms to those builders and the
|
|
61
|
+
wallet executor. See [CLAUDE.md](CLAUDE.md).
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
0BSD
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Access Gate Operator · MeddleWare</title>
|
|
7
|
+
<meta name="description" content="Create and manage on-chain access gates on Sui. Sell NFT-gated access to any resource; your wallet signs and pays. Client-side operator console." />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meddleware/access-gate-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Operator console for creating and managing on-chain Sui access gates — create gates, configure settings, airdrop NFTs, and freeze gates via a wallet-connected SPA.",
|
|
5
|
+
"author": "MeddleWare <meddleware@proton.me>",
|
|
6
|
+
"license": "0BSD",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/meddleware-org/access-gate-ui.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"index.html",
|
|
15
|
+
"vite.config.ts",
|
|
16
|
+
"tsconfig.json",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "vite",
|
|
21
|
+
"build": "vue-tsc --noEmit && vite build",
|
|
22
|
+
"preview": "vite preview",
|
|
23
|
+
"type-check": "vue-tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@meddleware/design-tokens": "^0.1.0",
|
|
30
|
+
"@meddleware/ui": "^0.1.0",
|
|
31
|
+
"@meddleware/nft-gate-client": "^0.0.3",
|
|
32
|
+
"@mysten/sui": "~2.17.0",
|
|
33
|
+
"@mysten/wallet-standard": "~0.19.9",
|
|
34
|
+
"vue": "^3.5.40"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "~24.12.2",
|
|
38
|
+
"@vitejs/plugin-vue": "^6.0.8",
|
|
39
|
+
"typescript": "~6.0.0",
|
|
40
|
+
"vite": "^8.1.5",
|
|
41
|
+
"vue-tsc": "~3.3.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": "^22.18.0 || >=24.12.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/App.vue
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { UiButton, UiNotice } from '@meddleware/ui'
|
|
4
|
+
import type { OwnedGate } from '@meddleware/nft-gate-client'
|
|
5
|
+
import { NETWORK } from './config.js'
|
|
6
|
+
import { PACKAGE_ID, listMyGates } from './gates.js'
|
|
7
|
+
import { useWallet } from './wallet.js'
|
|
8
|
+
import CreateGateForm from './components/CreateGateForm.vue'
|
|
9
|
+
import GateList from './components/GateList.vue'
|
|
10
|
+
|
|
11
|
+
type Tab = 'create' | 'gates'
|
|
12
|
+
const activeTab = ref<Tab>('gates')
|
|
13
|
+
|
|
14
|
+
const { wallets, account, connect, disconnect } = useWallet()
|
|
15
|
+
|
|
16
|
+
const gates = ref<OwnedGate[]>([])
|
|
17
|
+
const loadingGates = ref(false)
|
|
18
|
+
const loadError = ref<string | null>(null)
|
|
19
|
+
|
|
20
|
+
const deployed = PACKAGE_ID !== ''
|
|
21
|
+
|
|
22
|
+
async function reloadGates(): Promise<void> {
|
|
23
|
+
if (!account.value || !deployed) return
|
|
24
|
+
loadingGates.value = true
|
|
25
|
+
loadError.value = null
|
|
26
|
+
try {
|
|
27
|
+
gates.value = await listMyGates(account.value.address)
|
|
28
|
+
} catch (e) {
|
|
29
|
+
loadError.value = e instanceof Error ? e.message : String(e)
|
|
30
|
+
} finally {
|
|
31
|
+
loadingGates.value = false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function onConnectFirst(): Promise<void> {
|
|
36
|
+
const w = wallets.value[0]
|
|
37
|
+
if (!w) return
|
|
38
|
+
await connect(w)
|
|
39
|
+
await reloadGates()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onCreated(): void {
|
|
43
|
+
activeTab.value = 'gates'
|
|
44
|
+
void reloadGates()
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="page">
|
|
50
|
+
<header class="head">
|
|
51
|
+
<div>
|
|
52
|
+
<h1>Access Gate Operator</h1>
|
|
53
|
+
<p class="sub">Create and manage on-chain access gates on Sui ({{ NETWORK }}).</p>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="wallet">
|
|
56
|
+
<template v-if="account">
|
|
57
|
+
<span class="addr">{{ account.address.slice(0, 8) }}…{{ account.address.slice(-4) }}</span>
|
|
58
|
+
<UiButton variant="ghost" @click="disconnect">Disconnect</UiButton>
|
|
59
|
+
</template>
|
|
60
|
+
<template v-else>
|
|
61
|
+
<UiButton :disabled="!wallets.length" @click="onConnectFirst">
|
|
62
|
+
{{ wallets.length ? 'Connect wallet' : 'No wallet detected' }}
|
|
63
|
+
</UiButton>
|
|
64
|
+
</template>
|
|
65
|
+
</div>
|
|
66
|
+
</header>
|
|
67
|
+
|
|
68
|
+
<UiNotice v-if="!deployed" type="error">
|
|
69
|
+
The access_gate contract is not deployed on {{ NETWORK }}. Switch to a supported network.
|
|
70
|
+
</UiNotice>
|
|
71
|
+
|
|
72
|
+
<template v-else-if="account">
|
|
73
|
+
<nav class="tabs" aria-label="Sections">
|
|
74
|
+
<button type="button" class="tab" :class="{ active: activeTab === 'gates' }" @click="activeTab = 'gates'; reloadGates()">
|
|
75
|
+
My gates
|
|
76
|
+
</button>
|
|
77
|
+
<button type="button" class="tab" :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">
|
|
78
|
+
Create gate
|
|
79
|
+
</button>
|
|
80
|
+
</nav>
|
|
81
|
+
|
|
82
|
+
<UiNotice v-if="loadError" type="error">{{ loadError }}</UiNotice>
|
|
83
|
+
|
|
84
|
+
<section v-if="activeTab === 'gates'">
|
|
85
|
+
<GateList :gates="gates" :loading="loadingGates" @changed="reloadGates" />
|
|
86
|
+
</section>
|
|
87
|
+
<section v-else>
|
|
88
|
+
<CreateGateForm :address="account.address" @created="onCreated" />
|
|
89
|
+
</section>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<UiNotice v-else type="info">
|
|
93
|
+
Connect a Sui wallet to create and manage your access gates.
|
|
94
|
+
</UiNotice>
|
|
95
|
+
|
|
96
|
+
<footer class="foot">
|
|
97
|
+
<p>© MeddleWare · <a href="https://sui.meddleware.co.uk">more SUI tools</a></p>
|
|
98
|
+
</footer>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<style scoped>
|
|
103
|
+
.page {
|
|
104
|
+
max-width: 720px;
|
|
105
|
+
margin: 0 auto;
|
|
106
|
+
padding: 2rem 1.25rem 4rem;
|
|
107
|
+
}
|
|
108
|
+
.head {
|
|
109
|
+
display: flex;
|
|
110
|
+
justify-content: space-between;
|
|
111
|
+
align-items: flex-start;
|
|
112
|
+
gap: 1rem;
|
|
113
|
+
flex-wrap: wrap;
|
|
114
|
+
}
|
|
115
|
+
.head h1 {
|
|
116
|
+
margin: 0;
|
|
117
|
+
font-size: 1.8rem;
|
|
118
|
+
color: var(--text);
|
|
119
|
+
}
|
|
120
|
+
.sub {
|
|
121
|
+
color: var(--muted);
|
|
122
|
+
margin: 0.25rem 0 0;
|
|
123
|
+
}
|
|
124
|
+
.wallet {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 0.75rem;
|
|
128
|
+
}
|
|
129
|
+
.addr {
|
|
130
|
+
font-family: monospace;
|
|
131
|
+
font-size: 0.9rem;
|
|
132
|
+
color: var(--text);
|
|
133
|
+
}
|
|
134
|
+
.tabs {
|
|
135
|
+
display: flex;
|
|
136
|
+
gap: 0.25rem;
|
|
137
|
+
margin: 1.25rem 0 1rem;
|
|
138
|
+
border-bottom: 2px solid var(--border);
|
|
139
|
+
}
|
|
140
|
+
.tab {
|
|
141
|
+
background: none;
|
|
142
|
+
border: none;
|
|
143
|
+
border-bottom: 2px solid transparent;
|
|
144
|
+
padding: 0.5rem 1rem;
|
|
145
|
+
margin-bottom: -2px;
|
|
146
|
+
cursor: pointer;
|
|
147
|
+
font-size: 0.95rem;
|
|
148
|
+
color: var(--muted);
|
|
149
|
+
}
|
|
150
|
+
.tab.active {
|
|
151
|
+
border-bottom-color: var(--mw-accent-500, var(--text));
|
|
152
|
+
color: var(--text);
|
|
153
|
+
font-weight: 600;
|
|
154
|
+
}
|
|
155
|
+
.foot {
|
|
156
|
+
margin-top: 3rem;
|
|
157
|
+
font-size: 0.85rem;
|
|
158
|
+
color: var(--muted);
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// AdminCap-gated free grant of the gate's NFT flavour to an address.
|
|
3
|
+
import { ref } from 'vue'
|
|
4
|
+
import { UiButton, UiNotice } from '@meddleware/ui'
|
|
5
|
+
import { buildAirdropTx } from '@meddleware/nft-gate-client'
|
|
6
|
+
import type { OwnedGate } from '@meddleware/nft-gate-client'
|
|
7
|
+
import { adminContext, executeTx } from '../gates.js'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
/** The gate whose NFT flavour is airdropped. */
|
|
11
|
+
gate: OwnedGate
|
|
12
|
+
}>()
|
|
13
|
+
const emit = defineEmits<{ (e: 'changed'): void }>()
|
|
14
|
+
|
|
15
|
+
const recipient = ref('')
|
|
16
|
+
const busy = ref(false)
|
|
17
|
+
const error = ref<string | null>(null)
|
|
18
|
+
const okDigest = ref<string | null>(null)
|
|
19
|
+
|
|
20
|
+
async function airdrop(): Promise<void> {
|
|
21
|
+
error.value = null
|
|
22
|
+
okDigest.value = null
|
|
23
|
+
busy.value = true
|
|
24
|
+
try {
|
|
25
|
+
const digest = await executeTx(buildAirdropTx(adminContext(props.gate), recipient.value.trim()))
|
|
26
|
+
okDigest.value = digest
|
|
27
|
+
recipient.value = ''
|
|
28
|
+
emit('changed')
|
|
29
|
+
} catch (e) {
|
|
30
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
31
|
+
} finally {
|
|
32
|
+
busy.value = false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div class="airdrop">
|
|
39
|
+
<label>Airdrop access to</label>
|
|
40
|
+
<div class="line">
|
|
41
|
+
<input v-model="recipient" type="text" placeholder="0x…" spellcheck="false" />
|
|
42
|
+
<UiButton variant="secondary" :disabled="busy || !recipient" @click="airdrop">
|
|
43
|
+
{{ busy ? 'Sending…' : 'Airdrop' }}
|
|
44
|
+
</UiButton>
|
|
45
|
+
</div>
|
|
46
|
+
<UiNotice v-if="error" type="error">{{ error }}</UiNotice>
|
|
47
|
+
<UiNotice v-else-if="okDigest" type="ok">Airdropped. Tx: {{ okDigest }}</UiNotice>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.airdrop {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: 0.4rem;
|
|
56
|
+
}
|
|
57
|
+
.airdrop > label {
|
|
58
|
+
font-size: 0.85rem;
|
|
59
|
+
color: var(--muted);
|
|
60
|
+
}
|
|
61
|
+
.line {
|
|
62
|
+
display: flex;
|
|
63
|
+
gap: 0.5rem;
|
|
64
|
+
}
|
|
65
|
+
.line input {
|
|
66
|
+
flex: 1;
|
|
67
|
+
min-width: 0;
|
|
68
|
+
padding: 0.4rem 0.55rem;
|
|
69
|
+
border: 1px solid var(--border);
|
|
70
|
+
border-radius: var(--mw-radius, 8px);
|
|
71
|
+
background: var(--surface);
|
|
72
|
+
color: var(--text);
|
|
73
|
+
font-size: 0.9rem;
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Create a new access gate. Builds `create_gate` via @meddleware/nft-gate-client under the
|
|
3
|
+
// hardcoded Meddleware package (commission enforced), then emits the tx digest on success.
|
|
4
|
+
import { ref } from 'vue'
|
|
5
|
+
import { UiCard, UiButton, UiNotice } from '@meddleware/ui'
|
|
6
|
+
import { buildCreateGateTx } from '@meddleware/nft-gate-client'
|
|
7
|
+
import { PACKAGE_ID, executeTx } from '../gates.js'
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
/** Connected operator address; prefilled as the default payment recipient. */
|
|
11
|
+
address: string
|
|
12
|
+
}>()
|
|
13
|
+
const emit = defineEmits<{ (e: 'created', digest: string): void }>()
|
|
14
|
+
|
|
15
|
+
const priceSui = ref('0')
|
|
16
|
+
const defaultUses = ref('0')
|
|
17
|
+
const soulbound = ref(false)
|
|
18
|
+
const autoBurnAtZero = ref(false)
|
|
19
|
+
const nftName = ref('')
|
|
20
|
+
const nftImageUrl = ref('')
|
|
21
|
+
const nftDescription = ref('')
|
|
22
|
+
const paymentRecipient = ref(props.address)
|
|
23
|
+
|
|
24
|
+
const submitting = ref(false)
|
|
25
|
+
const error = ref<string | null>(null)
|
|
26
|
+
const okDigest = ref<string | null>(null)
|
|
27
|
+
|
|
28
|
+
/** Convert a decimal SUI string to a MIST bigint (1 SUI = 1e9 MIST). */
|
|
29
|
+
function suiToMist(sui: string): bigint {
|
|
30
|
+
const n = Number(sui)
|
|
31
|
+
if (!Number.isFinite(n) || n < 0) throw new Error('Invalid price.')
|
|
32
|
+
return BigInt(Math.round(n * 1e9))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function submit(): Promise<void> {
|
|
36
|
+
error.value = null
|
|
37
|
+
okDigest.value = null
|
|
38
|
+
submitting.value = true
|
|
39
|
+
try {
|
|
40
|
+
const tx = buildCreateGateTx(PACKAGE_ID, {
|
|
41
|
+
priceMist: suiToMist(priceSui.value),
|
|
42
|
+
paymentRecipient: paymentRecipient.value.trim(),
|
|
43
|
+
defaultUses: BigInt(defaultUses.value || '0'),
|
|
44
|
+
soulbound: soulbound.value,
|
|
45
|
+
autoBurnAtZero: autoBurnAtZero.value,
|
|
46
|
+
nftName: nftName.value,
|
|
47
|
+
nftImageUrl: nftImageUrl.value,
|
|
48
|
+
nftDescription: nftDescription.value,
|
|
49
|
+
})
|
|
50
|
+
const digest = await executeTx(tx)
|
|
51
|
+
okDigest.value = digest
|
|
52
|
+
emit('created', digest)
|
|
53
|
+
} catch (e) {
|
|
54
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
55
|
+
} finally {
|
|
56
|
+
submitting.value = false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<UiCard title="Create a gate">
|
|
63
|
+
<form class="form" @submit.prevent="submit">
|
|
64
|
+
<label class="field">
|
|
65
|
+
<span>Price (SUI)</span>
|
|
66
|
+
<input v-model="priceSui" type="number" min="0" step="0.000000001" inputmode="decimal" />
|
|
67
|
+
<small>0 = free gate.</small>
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
<label class="field">
|
|
71
|
+
<span>Default uses</span>
|
|
72
|
+
<input v-model="defaultUses" type="number" min="0" step="1" />
|
|
73
|
+
<small>0 = unlimited pass; N = single-use NFT with N uses.</small>
|
|
74
|
+
</label>
|
|
75
|
+
|
|
76
|
+
<label class="check">
|
|
77
|
+
<input v-model="soulbound" type="checkbox" />
|
|
78
|
+
<span>Soulbound (non-transferable NFTs)</span>
|
|
79
|
+
</label>
|
|
80
|
+
|
|
81
|
+
<label class="check">
|
|
82
|
+
<input v-model="autoBurnAtZero" type="checkbox" />
|
|
83
|
+
<span>Auto-burn single-use NFTs at zero uses</span>
|
|
84
|
+
</label>
|
|
85
|
+
|
|
86
|
+
<label class="field">
|
|
87
|
+
<span>Payment recipient</span>
|
|
88
|
+
<input v-model="paymentRecipient" type="text" placeholder="0x…" spellcheck="false" />
|
|
89
|
+
<small>Where purchase revenue is sent (after the platform commission).</small>
|
|
90
|
+
</label>
|
|
91
|
+
|
|
92
|
+
<label class="field">
|
|
93
|
+
<span>NFT name</span>
|
|
94
|
+
<input v-model="nftName" type="text" placeholder="My access pass" />
|
|
95
|
+
</label>
|
|
96
|
+
|
|
97
|
+
<label class="field">
|
|
98
|
+
<span>NFT image URL</span>
|
|
99
|
+
<input v-model="nftImageUrl" type="url" placeholder="https://…" spellcheck="false" />
|
|
100
|
+
</label>
|
|
101
|
+
|
|
102
|
+
<label class="field">
|
|
103
|
+
<span>NFT description</span>
|
|
104
|
+
<input v-model="nftDescription" type="text" placeholder="Grants access to…" />
|
|
105
|
+
</label>
|
|
106
|
+
|
|
107
|
+
<UiButton type="submit" :disabled="submitting">
|
|
108
|
+
{{ submitting ? 'Creating…' : 'Create gate' }}
|
|
109
|
+
</UiButton>
|
|
110
|
+
|
|
111
|
+
<UiNotice v-if="error" type="error">{{ error }}</UiNotice>
|
|
112
|
+
<UiNotice v-else-if="okDigest" type="ok">Gate created. Tx: {{ okDigest }}</UiNotice>
|
|
113
|
+
</form>
|
|
114
|
+
</UiCard>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style scoped>
|
|
118
|
+
.form {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: 0.9rem;
|
|
122
|
+
}
|
|
123
|
+
.field {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
gap: 0.3rem;
|
|
127
|
+
}
|
|
128
|
+
.field > span {
|
|
129
|
+
font-size: 0.9rem;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
color: var(--text);
|
|
132
|
+
}
|
|
133
|
+
.field input {
|
|
134
|
+
padding: 0.5rem 0.6rem;
|
|
135
|
+
border: 1px solid var(--border);
|
|
136
|
+
border-radius: var(--mw-radius, 8px);
|
|
137
|
+
background: var(--surface);
|
|
138
|
+
color: var(--text);
|
|
139
|
+
font-size: 0.95rem;
|
|
140
|
+
}
|
|
141
|
+
.field small {
|
|
142
|
+
color: var(--muted);
|
|
143
|
+
font-size: 0.8rem;
|
|
144
|
+
}
|
|
145
|
+
.check {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 0.5rem;
|
|
149
|
+
font-size: 0.9rem;
|
|
150
|
+
color: var(--text);
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Irreversible: make the gate immutable (consumes the AdminCap, permanently ending all setters
|
|
3
|
+
// and airdrop). Guarded behind a typed confirmation.
|
|
4
|
+
import { ref } from 'vue'
|
|
5
|
+
import { UiButton, UiNotice } from '@meddleware/ui'
|
|
6
|
+
import { buildMakeGateImmutableTx } from '@meddleware/nft-gate-client'
|
|
7
|
+
import type { OwnedGate } from '@meddleware/nft-gate-client'
|
|
8
|
+
import { adminContext, executeTx } from '../gates.js'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
/** The gate to freeze. */
|
|
12
|
+
gate: OwnedGate
|
|
13
|
+
}>()
|
|
14
|
+
const emit = defineEmits<{ (e: 'changed'): void }>()
|
|
15
|
+
|
|
16
|
+
const confirming = ref(false)
|
|
17
|
+
const confirmText = ref('')
|
|
18
|
+
const busy = ref(false)
|
|
19
|
+
const error = ref<string | null>(null)
|
|
20
|
+
|
|
21
|
+
async function freeze(): Promise<void> {
|
|
22
|
+
if (confirmText.value !== 'FREEZE') return
|
|
23
|
+
error.value = null
|
|
24
|
+
busy.value = true
|
|
25
|
+
try {
|
|
26
|
+
await executeTx(buildMakeGateImmutableTx(adminContext(props.gate)))
|
|
27
|
+
emit('changed')
|
|
28
|
+
} catch (e) {
|
|
29
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
30
|
+
} finally {
|
|
31
|
+
busy.value = false
|
|
32
|
+
confirming.value = false
|
|
33
|
+
confirmText.value = ''
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div class="freeze">
|
|
40
|
+
<template v-if="!confirming">
|
|
41
|
+
<UiButton variant="danger" @click="confirming = true">Freeze gate (irreversible)</UiButton>
|
|
42
|
+
</template>
|
|
43
|
+
<template v-else>
|
|
44
|
+
<UiNotice type="error">
|
|
45
|
+
Freezing is <strong>permanent</strong>: it destroys the AdminCap and ends all settings and
|
|
46
|
+
airdrops for this gate. Purchases and consumption continue. Type <code>FREEZE</code> to confirm.
|
|
47
|
+
</UiNotice>
|
|
48
|
+
<div class="line">
|
|
49
|
+
<input v-model="confirmText" type="text" placeholder="FREEZE" spellcheck="false" />
|
|
50
|
+
<UiButton variant="danger" :disabled="busy || confirmText !== 'FREEZE'" @click="freeze">
|
|
51
|
+
{{ busy ? 'Freezing…' : 'Confirm freeze' }}
|
|
52
|
+
</UiButton>
|
|
53
|
+
<UiButton variant="ghost" :disabled="busy" @click="confirming = false; confirmText = ''">
|
|
54
|
+
Cancel
|
|
55
|
+
</UiButton>
|
|
56
|
+
</div>
|
|
57
|
+
<UiNotice v-if="error" type="error">{{ error }}</UiNotice>
|
|
58
|
+
</template>
|
|
59
|
+
</div>
|
|
60
|
+
</template>
|
|
61
|
+
|
|
62
|
+
<style scoped>
|
|
63
|
+
.freeze {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
gap: 0.5rem;
|
|
67
|
+
}
|
|
68
|
+
.line {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
gap: 0.5rem;
|
|
72
|
+
align-items: center;
|
|
73
|
+
}
|
|
74
|
+
.line input {
|
|
75
|
+
padding: 0.4rem 0.55rem;
|
|
76
|
+
border: 1px solid var(--border);
|
|
77
|
+
border-radius: var(--mw-radius, 8px);
|
|
78
|
+
background: var(--surface);
|
|
79
|
+
color: var(--text);
|
|
80
|
+
font-size: 0.9rem;
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One administered gate: summary of its on-chain state, expandable into the management surface
|
|
3
|
+
// (settings, airdrop, freeze). A frozen gate shows a locked notice instead of controls.
|
|
4
|
+
import { ref } from 'vue'
|
|
5
|
+
import { UiCard, UiButton, UiNotice } from '@meddleware/ui'
|
|
6
|
+
import type { OwnedGate } from '@meddleware/nft-gate-client'
|
|
7
|
+
import GateSettingsPanel from './GateSettingsPanel.vue'
|
|
8
|
+
import AirdropForm from './AirdropForm.vue'
|
|
9
|
+
import FreezeGateButton from './FreezeGateButton.vue'
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
/** The gate to display and manage. */
|
|
13
|
+
gate: OwnedGate
|
|
14
|
+
}>()
|
|
15
|
+
const emit = defineEmits<{ (e: 'changed'): void }>()
|
|
16
|
+
|
|
17
|
+
const open = ref(false)
|
|
18
|
+
|
|
19
|
+
/** Short 0x…tail form for object ids. */
|
|
20
|
+
function short(id: string): string {
|
|
21
|
+
return id.length > 14 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id
|
|
22
|
+
}
|
|
23
|
+
function priceLabel(mist: bigint): string {
|
|
24
|
+
return mist === 0n ? 'Free' : `${(Number(mist) / 1e9).toString()} SUI`
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<UiCard>
|
|
30
|
+
<div class="head">
|
|
31
|
+
<div>
|
|
32
|
+
<h3>{{ gate.nftName || 'Untitled gate' }}</h3>
|
|
33
|
+
<p class="id"><code>{{ short(gate.gateId) }}</code></p>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="badges">
|
|
36
|
+
<span class="badge">{{ priceLabel(gate.priceMist) }}</span>
|
|
37
|
+
<span class="badge">{{ gate.defaultUses === 0n ? 'Unlimited' : `${gate.defaultUses} uses` }}</span>
|
|
38
|
+
<span v-if="gate.soulbound" class="badge">Soulbound</span>
|
|
39
|
+
<span v-if="gate.paused" class="badge badge--warn">Paused</span>
|
|
40
|
+
<span v-if="gate.frozen" class="badge badge--warn">Frozen</span>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="actions">
|
|
45
|
+
<UiButton variant="ghost" @click="open = !open">{{ open ? 'Hide' : 'Manage' }}</UiButton>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div v-if="open" class="manage">
|
|
49
|
+
<UiNotice v-if="gate.frozen" type="info">
|
|
50
|
+
This gate is frozen. Its settings and airdrops are permanently disabled; purchases and
|
|
51
|
+
consumption continue.
|
|
52
|
+
</UiNotice>
|
|
53
|
+
<template v-else>
|
|
54
|
+
<GateSettingsPanel :gate="gate" @changed="emit('changed')" />
|
|
55
|
+
<hr />
|
|
56
|
+
<AirdropForm :gate="gate" @changed="emit('changed')" />
|
|
57
|
+
<hr />
|
|
58
|
+
<FreezeGateButton :gate="gate" @changed="emit('changed')" />
|
|
59
|
+
</template>
|
|
60
|
+
</div>
|
|
61
|
+
</UiCard>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<style scoped>
|
|
65
|
+
.head {
|
|
66
|
+
display: flex;
|
|
67
|
+
justify-content: space-between;
|
|
68
|
+
align-items: flex-start;
|
|
69
|
+
gap: 1rem;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
}
|
|
72
|
+
.head h3 {
|
|
73
|
+
margin: 0;
|
|
74
|
+
font-size: 1.1rem;
|
|
75
|
+
color: var(--text);
|
|
76
|
+
}
|
|
77
|
+
.id {
|
|
78
|
+
margin: 0.15rem 0 0;
|
|
79
|
+
color: var(--muted);
|
|
80
|
+
font-size: 0.8rem;
|
|
81
|
+
}
|
|
82
|
+
.badges {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-wrap: wrap;
|
|
85
|
+
gap: 0.35rem;
|
|
86
|
+
}
|
|
87
|
+
.badge {
|
|
88
|
+
font-size: 0.75rem;
|
|
89
|
+
padding: 0.15rem 0.5rem;
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
border: 1px solid var(--border);
|
|
92
|
+
color: var(--muted);
|
|
93
|
+
}
|
|
94
|
+
.badge--warn {
|
|
95
|
+
color: var(--mw-gold-500, #b8860b);
|
|
96
|
+
border-color: currentColor;
|
|
97
|
+
}
|
|
98
|
+
.actions {
|
|
99
|
+
margin-top: 0.75rem;
|
|
100
|
+
}
|
|
101
|
+
.manage {
|
|
102
|
+
margin-top: 0.75rem;
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
gap: 0.75rem;
|
|
106
|
+
}
|
|
107
|
+
.manage hr {
|
|
108
|
+
border: none;
|
|
109
|
+
border-top: 1px solid var(--border);
|
|
110
|
+
margin: 0.25rem 0;
|
|
111
|
+
width: 100%;
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Renders the gates the connected operator administers. Pure presentation — the parent owns the
|
|
3
|
+
// data and passes it in; row-level changes bubble up via `changed` so the parent re-fetches.
|
|
4
|
+
import { UiNotice } from '@meddleware/ui'
|
|
5
|
+
import type { OwnedGate } from '@meddleware/nft-gate-client'
|
|
6
|
+
import GateCard from './GateCard.vue'
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
/** Gates the connected wallet administers. */
|
|
10
|
+
gates: OwnedGate[]
|
|
11
|
+
/** Whether the list is currently loading. */
|
|
12
|
+
loading: boolean
|
|
13
|
+
}>()
|
|
14
|
+
const emit = defineEmits<{ (e: 'changed'): void }>()
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="list">
|
|
19
|
+
<p v-if="loading" class="muted">Loading your gates…</p>
|
|
20
|
+
<UiNotice v-else-if="gates.length === 0" type="info">
|
|
21
|
+
You don't administer any gates yet. Create one in the "Create gate" tab.
|
|
22
|
+
</UiNotice>
|
|
23
|
+
<GateCard
|
|
24
|
+
v-for="gate in gates"
|
|
25
|
+
:key="gate.gateId"
|
|
26
|
+
:gate="gate"
|
|
27
|
+
@changed="emit('changed')"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<style scoped>
|
|
33
|
+
.list {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: 1rem;
|
|
37
|
+
}
|
|
38
|
+
.muted {
|
|
39
|
+
color: var(--muted);
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// AdminCap-gated settings for one gate. Each control builds its specific setter PTB via
|
|
3
|
+
// @meddleware/nft-gate-client and executes it; on success it emits `changed` so the parent
|
|
4
|
+
// re-reads the gate's on-chain state.
|
|
5
|
+
import { ref } from 'vue'
|
|
6
|
+
import type { Transaction } from '@mysten/sui/transactions'
|
|
7
|
+
import { UiButton, UiNotice } from '@meddleware/ui'
|
|
8
|
+
import type { OwnedGate } from '@meddleware/nft-gate-client'
|
|
9
|
+
import {
|
|
10
|
+
buildSetPriceTx,
|
|
11
|
+
buildSetPaymentRecipientTx,
|
|
12
|
+
buildSetPausedTx,
|
|
13
|
+
buildSetDefaultUsesTx,
|
|
14
|
+
buildSetSoulboundTx,
|
|
15
|
+
buildSetAutoBurnAtZeroTx,
|
|
16
|
+
buildSetNftNameTx,
|
|
17
|
+
buildSetNftImageUrlTx,
|
|
18
|
+
buildSetNftDescriptionTx,
|
|
19
|
+
} from '@meddleware/nft-gate-client'
|
|
20
|
+
import { adminContext, executeTx } from '../gates.js'
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
/** The gate being administered (supplies gateId + adminCapId + current values). */
|
|
24
|
+
gate: OwnedGate
|
|
25
|
+
}>()
|
|
26
|
+
const emit = defineEmits<{ (e: 'changed'): void }>()
|
|
27
|
+
|
|
28
|
+
// Local editable copies seeded from current on-chain state.
|
|
29
|
+
const priceSui = ref((Number(props.gate.priceMist) / 1e9).toString())
|
|
30
|
+
const paymentRecipient = ref(props.gate.paymentRecipient)
|
|
31
|
+
const defaultUses = ref(props.gate.defaultUses.toString())
|
|
32
|
+
const nftName = ref(props.gate.nftName)
|
|
33
|
+
const nftImageUrl = ref(props.gate.nftImageUrl)
|
|
34
|
+
const nftDescription = ref(props.gate.nftDescription)
|
|
35
|
+
|
|
36
|
+
const busy = ref<string | null>(null)
|
|
37
|
+
const error = ref<string | null>(null)
|
|
38
|
+
|
|
39
|
+
function suiToMist(sui: string): bigint {
|
|
40
|
+
const n = Number(sui)
|
|
41
|
+
if (!Number.isFinite(n) || n < 0) throw new Error('Invalid price.')
|
|
42
|
+
return BigInt(Math.round(n * 1e9))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build (lazily, so validation errors surface here) + execute one setter, keyed by `field`. */
|
|
46
|
+
async function run(field: string, build: () => Transaction): Promise<void> {
|
|
47
|
+
error.value = null
|
|
48
|
+
busy.value = field
|
|
49
|
+
try {
|
|
50
|
+
await executeTx(build())
|
|
51
|
+
emit('changed')
|
|
52
|
+
} catch (e) {
|
|
53
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
54
|
+
} finally {
|
|
55
|
+
busy.value = null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ctx = () => adminContext(props.gate)
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<div class="settings">
|
|
64
|
+
<div class="row">
|
|
65
|
+
<label>Price (SUI)</label>
|
|
66
|
+
<input v-model="priceSui" type="number" min="0" step="0.000000001" />
|
|
67
|
+
<UiButton variant="secondary" :disabled="busy !== null" @click="run('price', () => buildSetPriceTx(ctx(), suiToMist(priceSui)))">
|
|
68
|
+
{{ busy === 'price' ? '…' : 'Update' }}
|
|
69
|
+
</UiButton>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="row">
|
|
73
|
+
<label>Payment recipient</label>
|
|
74
|
+
<input v-model="paymentRecipient" type="text" placeholder="0x…" spellcheck="false" />
|
|
75
|
+
<UiButton variant="secondary" :disabled="busy !== null" @click="run('recipient', () => buildSetPaymentRecipientTx(ctx(), paymentRecipient.trim()))">
|
|
76
|
+
{{ busy === 'recipient' ? '…' : 'Update' }}
|
|
77
|
+
</UiButton>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="row">
|
|
81
|
+
<label>Default uses</label>
|
|
82
|
+
<input v-model="defaultUses" type="number" min="0" step="1" />
|
|
83
|
+
<UiButton variant="secondary" :disabled="busy !== null" @click="run('uses', () => buildSetDefaultUsesTx(ctx(), BigInt(defaultUses || '0')))">
|
|
84
|
+
{{ busy === 'uses' ? '…' : 'Update' }}
|
|
85
|
+
</UiButton>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="row">
|
|
89
|
+
<label>NFT name</label>
|
|
90
|
+
<input v-model="nftName" type="text" />
|
|
91
|
+
<UiButton variant="secondary" :disabled="busy !== null" @click="run('name', () => buildSetNftNameTx(ctx(), nftName))">
|
|
92
|
+
{{ busy === 'name' ? '…' : 'Update' }}
|
|
93
|
+
</UiButton>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="row">
|
|
97
|
+
<label>NFT image URL</label>
|
|
98
|
+
<input v-model="nftImageUrl" type="url" spellcheck="false" />
|
|
99
|
+
<UiButton variant="secondary" :disabled="busy !== null" @click="run('image', () => buildSetNftImageUrlTx(ctx(), nftImageUrl))">
|
|
100
|
+
{{ busy === 'image' ? '…' : 'Update' }}
|
|
101
|
+
</UiButton>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="row">
|
|
105
|
+
<label>NFT description</label>
|
|
106
|
+
<input v-model="nftDescription" type="text" />
|
|
107
|
+
<UiButton variant="secondary" :disabled="busy !== null" @click="run('desc', () => buildSetNftDescriptionTx(ctx(), nftDescription))">
|
|
108
|
+
{{ busy === 'desc' ? '…' : 'Update' }}
|
|
109
|
+
</UiButton>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="toggles">
|
|
113
|
+
<UiButton variant="ghost" :disabled="busy !== null" @click="run('paused', () => buildSetPausedTx(ctx(), !gate.paused))">
|
|
114
|
+
{{ gate.paused ? 'Unpause purchases' : 'Pause purchases' }}
|
|
115
|
+
</UiButton>
|
|
116
|
+
<UiButton variant="ghost" :disabled="busy !== null" @click="run('soulbound', () => buildSetSoulboundTx(ctx(), !gate.soulbound))">
|
|
117
|
+
{{ gate.soulbound ? 'Make future NFTs transferable' : 'Make future NFTs soulbound' }}
|
|
118
|
+
</UiButton>
|
|
119
|
+
<UiButton variant="ghost" :disabled="busy !== null" @click="run('autoburn', () => buildSetAutoBurnAtZeroTx(ctx(), !gate.autoBurnAtZero))">
|
|
120
|
+
{{ gate.autoBurnAtZero ? 'Keep spent NFTs as receipts' : 'Auto-burn spent NFTs' }}
|
|
121
|
+
</UiButton>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<UiNotice v-if="error" type="error">{{ error }}</UiNotice>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style scoped>
|
|
129
|
+
.settings {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-direction: column;
|
|
132
|
+
gap: 0.6rem;
|
|
133
|
+
}
|
|
134
|
+
.row {
|
|
135
|
+
display: grid;
|
|
136
|
+
grid-template-columns: 9rem 1fr auto;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 0.5rem;
|
|
139
|
+
}
|
|
140
|
+
.row label {
|
|
141
|
+
font-size: 0.85rem;
|
|
142
|
+
color: var(--muted);
|
|
143
|
+
}
|
|
144
|
+
.row input {
|
|
145
|
+
padding: 0.4rem 0.55rem;
|
|
146
|
+
border: 1px solid var(--border);
|
|
147
|
+
border-radius: var(--mw-radius, 8px);
|
|
148
|
+
background: var(--surface);
|
|
149
|
+
color: var(--text);
|
|
150
|
+
font-size: 0.9rem;
|
|
151
|
+
min-width: 0;
|
|
152
|
+
}
|
|
153
|
+
.toggles {
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-wrap: wrap;
|
|
156
|
+
gap: 0.5rem;
|
|
157
|
+
margin-top: 0.25rem;
|
|
158
|
+
}
|
|
159
|
+
</style>
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Build-time configuration (Vite inlines VITE_*). Only the network and RPC URLs are
|
|
2
|
+
// operator-configurable; the access_gate packageId + PlatformConfig id are hardcoded in
|
|
3
|
+
// constants.ts (commission enforcement).
|
|
4
|
+
|
|
5
|
+
/** Sui network. The access_gate contract is deployed per-network. */
|
|
6
|
+
export type SuiNetwork = 'testnet' | 'mainnet'
|
|
7
|
+
|
|
8
|
+
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
|
|
9
|
+
|
|
10
|
+
/** Active network, from `VITE_NETWORK` (default `testnet`). */
|
|
11
|
+
export const NETWORK: SuiNetwork = (env.VITE_NETWORK as SuiNetwork) || 'testnet'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* JSON-RPC endpoint used to build + execute gate transactions and read gate state.
|
|
15
|
+
* NOTE: this is JSON-RPC — the public testnet fullnode serves gRPC only, so a JSON-RPC-capable
|
|
16
|
+
* endpoint is used for testnet by default.
|
|
17
|
+
*/
|
|
18
|
+
export const RPC_URLS: Record<SuiNetwork, string> = {
|
|
19
|
+
testnet: env.VITE_RPC_TESTNET || 'https://sui-testnet-rpc.publicnode.com',
|
|
20
|
+
mainnet: env.VITE_RPC_MAINNET || 'https://fullnode.mainnet.sui.io:443',
|
|
21
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SuiNetwork } from './config.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Meddleware's deployed `access_gate` package IDs per network.
|
|
5
|
+
*
|
|
6
|
+
* **Hardcoded — commission enforcement.** Every gate created through this app is created under
|
|
7
|
+
* this package, so every `purchase` on those gates routes the on-chain 20 bps commission to
|
|
8
|
+
* Meddleware's `PlatformConfig` treasury. The package ID is intentionally NOT operator-configurable.
|
|
9
|
+
* Keep these in sync with `@meddleware/walrus-relay`'s `constants.ts` (the same deployment).
|
|
10
|
+
*/
|
|
11
|
+
export const ACCESS_GATE_PACKAGE_ID: Record<SuiNetwork, string> = {
|
|
12
|
+
testnet: '0x0bedd0b27d993d3292ca6a5315f7562de8bc0ff3752b445b4c53252c76f2d20d',
|
|
13
|
+
mainnet: '', // populated on mainnet deploy
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Meddleware's `PlatformConfig` shared object IDs per network. Governs the commission split on
|
|
18
|
+
* every NFT purchase. Not required to create/manage gates, but kept for display + purchase-preview.
|
|
19
|
+
*/
|
|
20
|
+
export const ACCESS_GATE_PLATFORM_CONFIG_ID: Record<SuiNetwork, string> = {
|
|
21
|
+
testnet: '0x7c5aed0ce7f29a4dfb60657858df31c12410a67098b4bcdd1d8cb1e531be4884',
|
|
22
|
+
mainnet: '', // populated on mainnet deploy
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fully-qualified NFT type minted by a gate, for the given soulbound flag. Derived from the
|
|
27
|
+
* hardcoded package ID — no separate configuration needed.
|
|
28
|
+
*/
|
|
29
|
+
export function accessGateNftType(network: SuiNetwork, soulbound: boolean): string {
|
|
30
|
+
const variant = soulbound ? 'SoulboundAccessNFT' : 'AccessNFT'
|
|
31
|
+
return `${ACCESS_GATE_PACKAGE_ID[network]}::access_gate::${variant}`
|
|
32
|
+
}
|
package/src/env.d.ts
ADDED
package/src/gates.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Thin bindings between the app (active network + hardcoded package) and the reusable
|
|
2
|
+
// @meddleware/nft-gate-client library. All PTB construction and on-chain reads live in the
|
|
3
|
+
// library; this module only supplies the network's client and the hardcoded package id.
|
|
4
|
+
import type { Transaction } from '@mysten/sui/transactions'
|
|
5
|
+
import { fetchOwnedGates, fetchGate } from '@meddleware/nft-gate-client'
|
|
6
|
+
import type { OwnedGate, GateAdminContext } from '@meddleware/nft-gate-client'
|
|
7
|
+
import { NETWORK } from './config.js'
|
|
8
|
+
import { ACCESS_GATE_PACKAGE_ID } from './constants.js'
|
|
9
|
+
import { getSuiClient, buildExecutor } from './wallet.js'
|
|
10
|
+
|
|
11
|
+
/** The access_gate package id for the active network (hardcoded — commission enforcement). */
|
|
12
|
+
export const PACKAGE_ID = ACCESS_GATE_PACKAGE_ID[NETWORK]
|
|
13
|
+
|
|
14
|
+
/** List every gate the given operator address administers (via their owned AdminCaps). */
|
|
15
|
+
export async function listMyGates(owner: string): Promise<OwnedGate[]> {
|
|
16
|
+
return fetchOwnedGates(getSuiClient(NETWORK), owner, PACKAGE_ID)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Re-read one gate's on-chain state (after a management tx), merging back its known adminCapId. */
|
|
20
|
+
export async function refreshGate(gate: OwnedGate): Promise<OwnedGate | null> {
|
|
21
|
+
const fresh = await fetchGate(getSuiClient(NETWORK), gate.gateId)
|
|
22
|
+
return fresh ? { ...fresh, adminCapId: gate.adminCapId } : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build the AdminCap-gated context (`{ packageId, gateId, adminCapId }`) for a gate's PTBs. */
|
|
26
|
+
export function adminContext(gate: OwnedGate): GateAdminContext {
|
|
27
|
+
return { packageId: PACKAGE_ID, gateId: gate.gateId, adminCapId: gate.adminCapId }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sign + execute a built PTB with the connected wallet on the active network and wait for
|
|
32
|
+
* finality. Returns the transaction digest.
|
|
33
|
+
*
|
|
34
|
+
* @throws {Error} if no wallet is connected or the wallet rejects/execution fails.
|
|
35
|
+
*/
|
|
36
|
+
export async function executeTx(tx: Transaction): Promise<string> {
|
|
37
|
+
const executor = await buildExecutor(NETWORK)
|
|
38
|
+
const { digest } = await executor.signAndExecute(tx)
|
|
39
|
+
await executor.waitForTransaction(digest).catch(() => {})
|
|
40
|
+
return digest
|
|
41
|
+
}
|
|
42
|
+
|
package/src/main.ts
ADDED
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Minimal Sui wallet integration on @mysten/wallet-standard (dapp-kit is React-only).
|
|
2
|
+
// Discovers wallets, connects, and adapts the connected wallet into a transaction executor.
|
|
3
|
+
// Module singleton — all callers share one wallet/account state.
|
|
4
|
+
import { markRaw, readonly, ref, shallowRef } from 'vue'
|
|
5
|
+
import { getWallets, isWalletWithRequiredFeatureSet } from '@mysten/wallet-standard'
|
|
6
|
+
import type { Wallet, WalletAccount } from '@mysten/wallet-standard'
|
|
7
|
+
import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
|
|
8
|
+
import type { Transaction } from '@mysten/sui/transactions'
|
|
9
|
+
import type { SuiNetwork } from './config.js'
|
|
10
|
+
import { RPC_URLS } from './config.js'
|
|
11
|
+
|
|
12
|
+
const REQUIRED_FEATURES = ['standard:connect', 'sui:signTransaction'] as const
|
|
13
|
+
|
|
14
|
+
const wallets = shallowRef<Wallet[]>([])
|
|
15
|
+
const currentWallet = shallowRef<Wallet | null>(null)
|
|
16
|
+
const account = shallowRef<WalletAccount | null>(null)
|
|
17
|
+
const connecting = ref(false)
|
|
18
|
+
const error = ref<string | null>(null)
|
|
19
|
+
|
|
20
|
+
const clients = new Map<SuiNetwork, SuiJsonRpcClient>()
|
|
21
|
+
/** Return a memoised {@link SuiJsonRpcClient} for the network (one instance per network). */
|
|
22
|
+
export function getSuiClient(network: SuiNetwork): SuiJsonRpcClient {
|
|
23
|
+
let c = clients.get(network)
|
|
24
|
+
if (!c) {
|
|
25
|
+
c = new SuiJsonRpcClient({ url: RPC_URLS[network], network })
|
|
26
|
+
clients.set(network, c)
|
|
27
|
+
}
|
|
28
|
+
return c
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refreshWallets(): void {
|
|
32
|
+
// markRaw: extension Wallet objects expose name/icon as ES-private-field getters
|
|
33
|
+
// that throw when accessed through a Vue reactive Proxy.
|
|
34
|
+
wallets.value = getWallets()
|
|
35
|
+
.get()
|
|
36
|
+
.filter((w) => isWalletWithRequiredFeatureSet(w, [...REQUIRED_FEATURES]))
|
|
37
|
+
.map((w) => markRaw(w))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let initialised = false
|
|
41
|
+
function init(): void {
|
|
42
|
+
if (initialised) return
|
|
43
|
+
initialised = true
|
|
44
|
+
const api = getWallets()
|
|
45
|
+
refreshWallets()
|
|
46
|
+
api.on('register', refreshWallets)
|
|
47
|
+
api.on('unregister', refreshWallets)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function connect(wallet: Wallet): Promise<void> {
|
|
51
|
+
error.value = null
|
|
52
|
+
connecting.value = true
|
|
53
|
+
try {
|
|
54
|
+
const feature = wallet.features['standard:connect'] as {
|
|
55
|
+
connect: () => Promise<{ accounts: readonly WalletAccount[] }>
|
|
56
|
+
}
|
|
57
|
+
const { accounts } = await feature.connect()
|
|
58
|
+
if (!accounts.length) throw new Error('Wallet returned no accounts.')
|
|
59
|
+
currentWallet.value = markRaw(wallet)
|
|
60
|
+
account.value = markRaw(accounts[0])
|
|
61
|
+
} catch (e) {
|
|
62
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
63
|
+
throw e
|
|
64
|
+
} finally {
|
|
65
|
+
connecting.value = false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function disconnect(): void {
|
|
70
|
+
const disc = currentWallet.value?.features['standard:disconnect'] as
|
|
71
|
+
| { disconnect?: () => Promise<void> }
|
|
72
|
+
| undefined
|
|
73
|
+
void disc?.disconnect?.()
|
|
74
|
+
currentWallet.value = null
|
|
75
|
+
account.value = null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Transaction executor bound to the connected wallet: sign+execute a PTB and await finality. */
|
|
79
|
+
export interface Executor {
|
|
80
|
+
address: string
|
|
81
|
+
signAndExecute(tx: Transaction): Promise<{ digest: string }>
|
|
82
|
+
waitForTransaction(digest: string): Promise<unknown>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build an executor bound to the connected wallet + network. */
|
|
86
|
+
export async function buildExecutor(network: SuiNetwork): Promise<Executor> {
|
|
87
|
+
const wallet = currentWallet.value
|
|
88
|
+
const acct = account.value
|
|
89
|
+
if (!wallet || !acct) throw new Error('Connect a wallet first.')
|
|
90
|
+
const client = getSuiClient(network)
|
|
91
|
+
const chain = `sui:${network}` as const
|
|
92
|
+
|
|
93
|
+
const signFeature = wallet.features['sui:signTransaction'] as {
|
|
94
|
+
signTransaction: (input: {
|
|
95
|
+
transaction: Transaction
|
|
96
|
+
account: WalletAccount
|
|
97
|
+
chain: `sui:${string}`
|
|
98
|
+
}) => Promise<{ bytes: string; signature: string }>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
address: acct.address,
|
|
103
|
+
async signAndExecute(tx: Transaction): Promise<{ digest: string }> {
|
|
104
|
+
const { bytes, signature } = await signFeature.signTransaction({
|
|
105
|
+
transaction: tx,
|
|
106
|
+
account: acct,
|
|
107
|
+
chain,
|
|
108
|
+
})
|
|
109
|
+
const res = await client.executeTransactionBlock({
|
|
110
|
+
transactionBlock: bytes,
|
|
111
|
+
signature,
|
|
112
|
+
options: { showEffects: true },
|
|
113
|
+
})
|
|
114
|
+
return { digest: res.digest }
|
|
115
|
+
},
|
|
116
|
+
async waitForTransaction(digest: string): Promise<unknown> {
|
|
117
|
+
return client.waitForTransaction({ digest })
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Wallet composable: discovers wallets, exposes reactive connection state, and provides
|
|
124
|
+
* `connect` / `disconnect` / `buildExecutor`. Module singleton — all callers share one state.
|
|
125
|
+
*/
|
|
126
|
+
export function useWallet() {
|
|
127
|
+
init()
|
|
128
|
+
return {
|
|
129
|
+
wallets: readonly(wallets),
|
|
130
|
+
currentWallet: readonly(currentWallet),
|
|
131
|
+
account: readonly(account),
|
|
132
|
+
connecting: readonly(connecting),
|
|
133
|
+
error: readonly(error),
|
|
134
|
+
connect,
|
|
135
|
+
disconnect,
|
|
136
|
+
buildExecutor,
|
|
137
|
+
}
|
|
138
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"jsx": "preserve",
|
|
14
|
+
"types": ["node"],
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "vite.config.ts"]
|
|
18
|
+
}
|