updated NFT usernames menu

This commit is contained in:
onysd 2026-08-06 14:41:53 +03:00
parent 36350f83dc
commit 7d41cbeb1e
8 changed files with 416 additions and 302 deletions

View file

@ -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
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;