updated NFT usernames menu
This commit is contained in:
parent
36350f83dc
commit
7d41cbeb1e
8 changed files with 416 additions and 302 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
10
cmd/telesrv-admin/web/dist/assets/index-DWg-er34.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-DWg-er34.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-BUraMCki.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-XySYVUb7.css">
|
||||
<script type="module" crossorigin src="/assets/index-DWg-er34.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bv1l1T8K.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import { Plus, Vault, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChannelPicker, UserPicker } from "./EntityPicker";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { currencyExponent, formatCurrency, toSmallestUnits } from "../lib/format";
|
||||
import type { AccountRow, ChannelRow, CollectibleCurrency } from "../types";
|
||||
|
||||
type OwnerKind = "vault" | "user" | "channel";
|
||||
|
||||
// MintCollectibleUsernameModal collects everything a new collectible needs,
|
||||
// grouped into clearly labeled steps (identity, owner, price, optional
|
||||
// marketplace record) instead of one flat wall of fields -- then hands off to
|
||||
// ActionButton for the usual reason/dry-run/confirm flow.
|
||||
export function MintCollectibleUsernameModal({ onClose, onMinted }: { onClose: () => void; onMinted: () => void }) {
|
||||
const [ownerKind, setOwnerKind] = useState<OwnerKind>("vault");
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [ownerChannel, setOwnerChannel] = useState<ChannelRow | null>(null);
|
||||
const [mintUsername, setMintUsername] = useState("");
|
||||
const [currency, setCurrency] = useState<CollectibleCurrency>("XTR");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [addCryptoLeg, setAddCryptoLeg] = useState(false);
|
||||
const [cryptoCurrency, setCryptoCurrency] = useState("TON");
|
||||
const [cryptoAmount, setCryptoAmount] = useState("");
|
||||
const [showRecord, setShowRecord] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [purchaseTime, setPurchaseTime] = useState("");
|
||||
|
||||
// int64 request fields are sent as decimal strings (the backend tags them
|
||||
// `,string`); purchase_date is Unix seconds. Both amounts are typed in whole
|
||||
// currency units and converted here: the API and fragment.collectibleInfo
|
||||
// carry smallest units, so 900 TON has to leave the panel as
|
||||
// 900000000000 nanotons or clients render 0.0000009.
|
||||
const minorAmount = toSmallestUnits(amount, currency);
|
||||
const minorCryptoAmount = addCryptoLeg ? toSmallestUnits(cryptoAmount, cryptoCurrency) : "0";
|
||||
const amountInvalid = minorAmount === null;
|
||||
const cryptoAmountInvalid = addCryptoLeg && minorCryptoAmount === null;
|
||||
const canSubmit = mintUsername.trim() !== "" && amount.trim() !== "" && !amountInvalid && !cryptoAmountInvalid
|
||||
&& (ownerKind === "vault" || (ownerKind === "user" ? owner !== null : ownerChannel !== null));
|
||||
|
||||
function mintPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: mintUsername.trim().replace(/^@/, ""),
|
||||
currency,
|
||||
amount: minorAmount ?? "0"
|
||||
};
|
||||
if (ownerKind === "user" && owner) payload.owner_user_id = String(owner.ID);
|
||||
if (ownerKind === "channel" && ownerChannel) payload.owner_channel_id = String(ownerChannel.ID);
|
||||
// The backend accepts either no crypto leg at all, or TON with a positive
|
||||
// nanoton amount -- never a currency without an amount.
|
||||
if (addCryptoLeg) {
|
||||
payload.crypto_currency = cryptoCurrency;
|
||||
payload.crypto_amount = minorCryptoAmount ?? "0";
|
||||
}
|
||||
if (url.trim()) payload.url = url.trim();
|
||||
if (purchaseDate) {
|
||||
// fragment.collectibleInfo.purchase_date is a unix timestamp, and the date
|
||||
// has always been read as UTC here. The time follows the same clock rather
|
||||
// than the operator's local one, so adding it cannot silently shift what a
|
||||
// date-only entry used to mean; the field label says UTC.
|
||||
const parsed = Date.parse(`${purchaseDate}T${purchaseTime || "00:00"}:00Z`);
|
||||
if (Number.isFinite(parsed)) payload.purchase_date = Math.floor(parsed / 1000);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Mint a collectible username"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"NFT usernames"}</div>
|
||||
<h2>{"Mint a collectible username"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="mint-field-group">
|
||||
<div className="mint-field-group-label">{"1. Username"}</div>
|
||||
<label className="duration-field">
|
||||
<span>{"Username"}</span>
|
||||
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mint-field-group">
|
||||
<div className="mint-field-group-label">{"2. Owner"}</div>
|
||||
<div className="toolbar" role="group" aria-label={"Owner type"}>
|
||||
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
|
||||
<Vault size={15} /> {"Vault (no owner)"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
|
||||
{"User owner"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
|
||||
{"Channel owner"}
|
||||
</button>
|
||||
</div>
|
||||
{ownerKind === "user" && <UserPicker label={"User owner"} value={owner} onChange={setOwner} />}
|
||||
{ownerKind === "channel" && <ChannelPicker label={"Channel owner"} value={ownerChannel} onChange={setOwnerChannel} />}
|
||||
{ownerKind === "vault" && <p className="bot-create-note">{"Mints the asset unassigned; issue it to someone later from the asset page."}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mint-field-group">
|
||||
<div className="mint-field-group-label">{"3. Price"}</div>
|
||||
<p className="bot-create-note">{"A record of what it was sold for -- minting doesn't charge anyone."}</p>
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Currency"}</span>
|
||||
<select value={currency} onChange={(event) => setCurrency(event.target.value as CollectibleCurrency)}>
|
||||
<option value="XTR">XTR</option>
|
||||
<option value="TON">TON</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{`Amount (${currency})`}</span>
|
||||
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
|
||||
</label>
|
||||
</div>
|
||||
{amount.trim() !== "" && !amountInvalid && (
|
||||
<p className="bot-create-note">{`Clients will show: ${formatCurrency(minorAmount ?? "0", currency)}.`}</p>
|
||||
)}
|
||||
{amountInvalid && <p className="bot-create-note">{`Not a valid ${currency} amount: digits only, at most ${String(currencyExponent(currency))} decimal places.`}</p>}
|
||||
<label className="checkline">
|
||||
<input type="checkbox" checked={addCryptoLeg} onChange={(event) => setAddCryptoLeg(event.target.checked)} />
|
||||
{" Also record a TON price"}
|
||||
</label>
|
||||
{addCryptoLeg && (
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Crypto currency"}</span>
|
||||
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
|
||||
<option value="TON">TON</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{`Crypto amount (${cryptoCurrency})`}</span>
|
||||
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{cryptoAmountInvalid && <p className="bot-create-note">{`Not a valid ${cryptoCurrency} amount: digits only, at most ${String(currencyExponent(cryptoCurrency))} decimal places.`}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mint-field-group">
|
||||
<button type="button" className="link-button" onClick={() => setShowRecord((v) => !v)}>
|
||||
{showRecord ? "Hide marketplace record" : "+ Add marketplace record (optional)"}
|
||||
</button>
|
||||
{showRecord && (
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Marketplace URL"}</span>
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Purchase date (UTC)"}</span>
|
||||
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Purchase time (UTC)"}</span>
|
||||
<input
|
||||
value={purchaseTime}
|
||||
onChange={(event) => setPurchaseTime(event.target.value)}
|
||||
type="time"
|
||||
step={60}
|
||||
disabled={!purchaseDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
|
||||
<ActionButton
|
||||
disabled={!canSubmit}
|
||||
label={"Mint username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/mint-collectible-username"
|
||||
payload={mintPayload}
|
||||
onDone={onMinted}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { ArrowLeft, ArrowLeftRight, ExternalLink, Flame, Trash2, RefreshCw, Undo2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ArrowLeftRight, ExternalLink, Flame, RefreshCw, ScrollText, Settings2, Trash2, Undo2, UserRound } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||
import { displayUsername, formatCurrency, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
|
|
@ -15,11 +15,13 @@ import type {
|
|||
import { UsernameStatus, ownerLabel, priceLabel } from "./CollectibleUsernamesPage";
|
||||
|
||||
type RecipientKind = "user" | "channel";
|
||||
type Tab = "profile" | "actions";
|
||||
|
||||
export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<CollectibleUsernameDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>("profile");
|
||||
const [recipientKind, setRecipientKind] = useState<RecipientKind>("user");
|
||||
const [recipientUser, setRecipientUser] = useState<AccountRow | null>(null);
|
||||
const [recipientChannel, setRecipientChannel] = useState<ChannelRow | null>(null);
|
||||
|
|
@ -38,6 +40,8 @@ export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; na
|
|||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
setTab("profile");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
if (error && !detail) {
|
||||
|
|
@ -66,6 +70,11 @@ export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; na
|
|||
return payload;
|
||||
}
|
||||
|
||||
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
|
||||
{ key: "profile", label: "Profile & Status", icon: <UserRound size={15} /> },
|
||||
{ key: "actions", label: "Actions & Management", icon: <Settings2 size={15} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Collectible ${displayUsername(asset.Username)}`}
|
||||
|
|
@ -82,67 +91,126 @@ export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; na
|
|||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(asset.Username)}</div>
|
||||
<div className="entity-subtitle">{`Asset #${asset.ID}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<UsernameStatus status={asset.Status} />
|
||||
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
|
||||
{`${asset.TransferCount} transfers`}
|
||||
</Badge>
|
||||
{asset.Status === "owned" && (
|
||||
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
|
||||
{asset.RegistryActive ? "Active in profile" : "Hidden in profile"}
|
||||
</Badge>
|
||||
)}
|
||||
<section className="entity-head">
|
||||
<div className="entity-head-main">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(asset.Username)}</div>
|
||||
<div className="entity-subtitle">{`Asset #${asset.ID}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<UsernameStatus status={asset.Status} />
|
||||
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>{`${asset.TransferCount} transfers`}</Badge>
|
||||
{asset.Status === "owned" && (
|
||||
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
|
||||
{asset.RegistryActive ? "Active in profile" : "Hidden in profile"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="toolbar" role="group" aria-label={"Asset sections"}>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={tab === item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "profile" && (
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Owner"} value={ownerLabel(asset, vaultLabel)} />
|
||||
<Summary label={"Price"} value={priceLabel(asset)} mono />
|
||||
<Summary label={"Purchase date (UTC)"} value={formatDate(asset.PurchaseDate) || "-"} />
|
||||
<Summary
|
||||
label={"Original owner"}
|
||||
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
|
||||
/>
|
||||
<Summary label={"Transfers"} value={String(asset.TransferCount)} mono />
|
||||
<Summary label={"Created"} value={formatDate(asset.CreatedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(asset.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
{hasOwner && (
|
||||
<button className="row-link" type="button" onClick={openOwner}>
|
||||
{asset.OwnerPeerType === "channel" ? "Open owner channel" : "Open owner account"}
|
||||
</button>
|
||||
)}
|
||||
{asset.URL && (
|
||||
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
|
||||
<ExternalLink size={14} /> {"Open marketplace page"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Provenance history"} text={"Mint, transfer, revoke and burn events in chronological order."} action={<ScrollText size={16} />} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Event"}</th>
|
||||
<th>{"From"}</th>
|
||||
<th>{"To"}</th>
|
||||
<th>{"Price"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transfers.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><TransferKind kind={row.Kind} /></td>
|
||||
<td className="mono">{peerLabel(row.FromPeerType, row.FromPeerID, vaultLabel, row.FromUsername)}</td>
|
||||
<td className="mono">{peerLabel(row.ToPeerType, row.ToPeerID, vaultLabel, row.ToUsername)}</td>
|
||||
<td className="mono">{row.Amount && row.Amount !== "0" ? formatCurrency(row.Amount, row.Currency) : "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transfers.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "actions" && (
|
||||
<div className="stacked-sections">
|
||||
{burned ? (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Asset Operations"} />
|
||||
<div className="card-body">
|
||||
<p className="bot-create-note">{"This username is burned — no further operations are possible."}</p>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Owner"} value={ownerLabel(asset, vaultLabel)} />
|
||||
<Summary label={"Price"} value={priceLabel(asset)} mono />
|
||||
<Summary label={"Purchase date (UTC)"} value={formatDate(asset.PurchaseDate) || "-"} />
|
||||
<Summary
|
||||
label={"Original owner"}
|
||||
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
|
||||
/>
|
||||
<Summary label={"Transfers"} value={String(asset.TransferCount)} mono />
|
||||
<Summary label={"Created"} value={formatDate(asset.CreatedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(asset.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
{hasOwner && (
|
||||
<button className="row-link" type="button" onClick={openOwner}>
|
||||
{asset.OwnerPeerType === "channel" ? "Open owner channel" : "Open owner account"}
|
||||
</button>
|
||||
)}
|
||||
{asset.URL && (
|
||||
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
|
||||
<ExternalLink size={14} /> {"Open marketplace page"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!burned && (
|
||||
) : (
|
||||
<div className="action-groups">
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Transfer ownership"} text={"Pick the recipient; the transfer is appended to the provenance history."} />
|
||||
<div className="toolbar" role="group" aria-label={"Recipient type"}>
|
||||
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
|
||||
{"To user"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
|
||||
{"To channel"}
|
||||
</button>
|
||||
</div>
|
||||
{recipientKind === "user"
|
||||
? <UserPicker label={"To user"} value={recipientUser} onChange={setRecipientUser} />
|
||||
: <ChannelPicker label={"To channel"} value={recipientChannel} onChange={setRecipientChannel} />}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{"The current owner loses the username immediately after confirmation."}</span>
|
||||
<SectionHead title={"Transfer Ownership"} text={"Sent immediately; appended to the provenance history."} />
|
||||
<div className="card-body">
|
||||
<div className="toolbar" role="group" aria-label={"Recipient type"}>
|
||||
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
|
||||
{"To user"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
|
||||
{"To channel"}
|
||||
</button>
|
||||
</div>
|
||||
{recipientKind === "user"
|
||||
? <UserPicker label={"To user"} value={recipientUser} onChange={setRecipientUser} />
|
||||
: <ChannelPicker label={"To channel"} value={recipientChannel} onChange={setRecipientChannel} />}
|
||||
<ActionButton
|
||||
label={"Transfer"}
|
||||
icon={<ArrowLeftRight size={15} />}
|
||||
|
|
@ -153,87 +221,52 @@ export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; na
|
|||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Provenance history"} text={"Mint, transfer, revoke and burn events in chronological order."} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Event"}</th>
|
||||
<th>{"From"}</th>
|
||||
<th>{"To"}</th>
|
||||
<th>{"Price"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transfers.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><TransferKind kind={row.Kind} /></td>
|
||||
<td className="mono">{peerLabel(row.FromPeerType, row.FromPeerID, vaultLabel, row.FromUsername)}</td>
|
||||
<td className="mono">{peerLabel(row.ToPeerType, row.ToPeerID, vaultLabel, row.ToUsername)}</td>
|
||||
<td className="mono">{row.Amount && row.Amount !== "0" ? formatCurrency(row.Amount, row.Currency) : "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transfers.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Asset operations"}</div>
|
||||
{burned ? (
|
||||
<p className="bot-create-note">{"This username is burned — no further operations are possible."}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Revoke to vault"}
|
||||
icon={<Undo2 size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: false })}
|
||||
onDone={load}
|
||||
/>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Revoke To Vault"} text={"Returns the username to the vault; it can be issued again later."} />
|
||||
<div className="card-body">
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Revoke to vault"}
|
||||
icon={<Undo2 size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: false })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Takes the username away from its owner and returns it to the vault; it can be issued again later."}</p>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={"Burn permanently"}
|
||||
icon={<Flame size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: true })}
|
||||
onDone={load}
|
||||
/>
|
||||
<p className="bot-create-note">{"Irreversible: the username is destroyed and can never be issued again."}</p>
|
||||
<ActionButton
|
||||
label={"Delete record"}
|
||||
icon={<Trash2 size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/delete-collectible-username"
|
||||
payload={() => ({ username: asset.Username })}
|
||||
onDone={() => navigate("/collectible-usernames")}
|
||||
/>
|
||||
<p className="bot-create-note">{"Erases the asset and its ownership history, and frees the username for a fresh issue. Use this for a username issued by mistake; a burn keeps the history instead."}</p>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Danger Zone"} />
|
||||
<div className="card-body">
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={"Burn permanently"}
|
||||
icon={<Flame size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: true })}
|
||||
onDone={load}
|
||||
/>
|
||||
<p className="bot-create-note">{"Irreversible: the username is destroyed and can never be issued again."}</p>
|
||||
<ActionButton
|
||||
label={"Delete record"}
|
||||
icon={<Trash2 size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/delete-collectible-username"
|
||||
payload={() => ({ username: asset.Username })}
|
||||
onDone={() => navigate("/collectible-usernames")}
|
||||
/>
|
||||
<p className="bot-create-note">{"Erases the asset and its ownership history, and frees the username for a fresh issue. Use this for a username issued by mistake; a burn keeps the history instead."}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
import { AtSign, ChevronDown, ChevronRight, Flame, Loader2, Plus, RefreshCw, Search, Vault } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { currencyExponent, displayUsername, formatCurrency, formatDate, toSmallestUnits } from "../lib/format";
|
||||
import { MintCollectibleUsernameModal } from "../components/MintCollectibleUsernameModal";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayUsername, formatCurrency, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
AccountRow,
|
||||
ChannelRow,
|
||||
CollectibleCurrency,
|
||||
CollectibleUsernameRow,
|
||||
CollectibleUsernameStatus
|
||||
} from "../types";
|
||||
import type { CollectibleUsernameRow, CollectibleUsernameStatus } from "../types";
|
||||
|
||||
type StatusFilter = "all" | CollectibleUsernameStatus;
|
||||
type OwnerKind = "vault" | "user" | "channel";
|
||||
|
||||
export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
|
|
@ -26,19 +18,7 @@ export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
|||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Mint form state.
|
||||
const [ownerKind, setOwnerKind] = useState<OwnerKind>("vault");
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [ownerChannel, setOwnerChannel] = useState<ChannelRow | null>(null);
|
||||
const [mintUsername, setMintUsername] = useState("");
|
||||
const [currency, setCurrency] = useState<CollectibleCurrency>("XTR");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [cryptoCurrency, setCryptoCurrency] = useState("");
|
||||
const [cryptoAmount, setCryptoAmount] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [purchaseTime, setPurchaseTime] = useState("");
|
||||
const [mintModalOpen, setMintModalOpen] = useState(false);
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
|
|
@ -68,51 +48,19 @@ export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
|||
const ownedCount = rows.filter((row) => row.Status === "owned").length;
|
||||
const burnedCount = rows.filter((row) => row.Status === "burned").length;
|
||||
|
||||
// int64 request fields are sent as decimal strings (the backend tags them
|
||||
// `,string`); purchase_date is Unix seconds. Optional owner keys are omitted
|
||||
// entirely rather than sent empty, because `,string,omitempty` cannot decode "".
|
||||
// Both amounts are typed in whole currency units and converted here: the API
|
||||
// and fragment.collectibleInfo carry smallest units, so 900 TON has to leave
|
||||
// the panel as 900000000000 nanotons or clients render 0.0000009.
|
||||
const minorAmount = toSmallestUnits(amount, currency);
|
||||
const minorCryptoAmount = cryptoCurrency ? toSmallestUnits(cryptoAmount, cryptoCurrency) : "0";
|
||||
const amountInvalid = minorAmount === null;
|
||||
const cryptoAmountInvalid = minorCryptoAmount === null;
|
||||
|
||||
function mintPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: mintUsername.trim().replace(/^@/, ""),
|
||||
currency,
|
||||
amount: minorAmount ?? "0"
|
||||
};
|
||||
if (ownerKind === "user" && owner) payload.owner_user_id = String(owner.ID);
|
||||
if (ownerKind === "channel" && ownerChannel) payload.owner_channel_id = String(ownerChannel.ID);
|
||||
// The backend accepts either no crypto leg at all, or TON with a positive
|
||||
// nanoton amount — never a currency without an amount.
|
||||
if (cryptoCurrency) {
|
||||
payload.crypto_currency = cryptoCurrency;
|
||||
payload.crypto_amount = minorCryptoAmount ?? "0";
|
||||
}
|
||||
if (url.trim()) payload.url = url.trim();
|
||||
if (purchaseDate) {
|
||||
// fragment.collectibleInfo.purchase_date is a unix timestamp, and the date has
|
||||
// always been read as UTC here. The time follows the same clock rather than the
|
||||
// operator's local one, so adding it cannot silently shift what a date-only
|
||||
// entry used to mean; the field label says UTC.
|
||||
const parsed = Date.parse(`${purchaseDate}T${purchaseTime || "00:00"}:00Z`);
|
||||
if (Number.isFinite(parsed)) payload.purchase_date = Math.floor(parsed / 1000);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Collectible usernames"}
|
||||
eyebrow={"NFT usernames / Registry"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
<>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => setMintModalOpen(true)}>
|
||||
<Plus size={15} /> {"Mint username"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
|
@ -123,91 +71,6 @@ export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
|||
<Metric label={"Burned"} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Mint a collectible username"} text={"Creates the asset together with its purchase record. Keep the owner as vault to mint it unassigned."} />
|
||||
<div className="toolbar" role="group" aria-label={"Owner type"}>
|
||||
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
|
||||
<Vault size={15} /> {"Vault (no owner)"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
|
||||
{"User owner"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
|
||||
{"Channel owner"}
|
||||
</button>
|
||||
</div>
|
||||
{ownerKind === "user" && <UserPicker label={"User owner"} value={owner} onChange={setOwner} />}
|
||||
{ownerKind === "channel" && <ChannelPicker label={"Channel owner"} value={ownerChannel} onChange={setOwnerChannel} />}
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Username"}</span>
|
||||
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Currency"}</span>
|
||||
<select value={currency} onChange={(event) => setCurrency(event.target.value as CollectibleCurrency)}>
|
||||
<option value="XTR">XTR</option>
|
||||
<option value="TON">TON</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{`Amount (${currency})`}</span>
|
||||
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Crypto currency"}</span>
|
||||
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
|
||||
<option value="">{"None"}</option>
|
||||
<option value="TON">TON</option>
|
||||
</select>
|
||||
</label>
|
||||
{cryptoCurrency !== "" && (
|
||||
<label className="duration-field">
|
||||
<span>{`Crypto amount (${cryptoCurrency})`}</span>
|
||||
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
|
||||
</label>
|
||||
)}
|
||||
<label className="duration-field">
|
||||
<span>{"Marketplace URL"}</span>
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Purchase date (UTC)"}</span>
|
||||
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Purchase time (UTC)"}</span>
|
||||
<input
|
||||
value={purchaseTime}
|
||||
onChange={(event) => setPurchaseTime(event.target.value)}
|
||||
type="time"
|
||||
step={60}
|
||||
disabled={!purchaseDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="bot-create-note">
|
||||
{`Amounts are typed in whole ${currency} and stored as the smallest units the API and fragment.collectibleInfo carry, so clients render the price you meant. Up to ${String(currencyExponent(currency))} decimal places. Clients will show: ${formatCurrency(minorAmount ?? "0", currency)}.`}
|
||||
</p>
|
||||
{amountInvalid && <Alert>{`That is not a valid ${currency} amount: digits only, with at most ${String(currencyExponent(currency))} decimal places.`}</Alert>}
|
||||
{cryptoCurrency !== "" && cryptoAmountInvalid && (
|
||||
<Alert>{`That is not a valid ${cryptoCurrency} amount: digits only, with at most ${String(currencyExponent(cryptoCurrency))} decimal places.`}</Alert>
|
||||
)}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{"Username, currency and amount are required; the dry-run checks availability first."}</span>
|
||||
<ActionButton
|
||||
disabled={amountInvalid || cryptoAmountInvalid}
|
||||
label={"Mint username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/mint-collectible-username"
|
||||
payload={mintPayload}
|
||||
onDone={() => load(false)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
|
|
@ -275,6 +138,13 @@ export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mintModalOpen && (
|
||||
<MintCollectibleUsernameModal
|
||||
onClose={() => setMintModalOpen(false)}
|
||||
onMinted={() => void load(false)}
|
||||
/>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,26 @@
|
|||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
/* Groups a long form (e.g. minting a collectible username) into clearly
|
||||
labeled steps instead of one flat wall of fields, so it's obvious what's
|
||||
required (username, owner, price) versus optional (marketplace record). */
|
||||
.mint-field-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.mint-field-group-label {
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.command-step {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue