UNPKG

bigblocks

Version:

Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React

18 lines 10.6 kB
{ "name": "inscriptions-components-royaltyeditor", "type": "registry:component", "dependencies": [ "@radix-ui/react-icons", "js-1sat-ord" ], "devDependencies": [], "registryDependencies": [], "files": [ { "path": "components/inscriptions/components/RoyaltyEditor.tsx", "type": "registry:component", "content": "\"use client\";\n\n/**\n * RoyaltyEditor Component\n *\n * Component for editing NFT collection royalty recipients with address/paymail and percentage\n * Stores values in localStorage for persistence across sessions\n */\n\nimport { MinusIcon, PlusIcon } from \"@radix-ui/react-icons\";\nimport { type Royalty, RoytaltyType } from \"js-1sat-ord\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { Badge } from \"../../ui/badge.js\";\nimport { Button } from \"../../ui/button.js\";\nimport { Input } from \"../../ui/input.js\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"../../ui/select.js\";\n\nexport interface RoyaltyEditorProps {\n\t/** Storage key for localStorage persistence */\n\tstorageKey?: string;\n\t/** Maximum total royalty percentage (default 7%) */\n\tmaxPercentage?: number;\n\t/** Callback when royalties change */\n\tonChange?: (royalties: Royalty[]) => void;\n\t/** Initial royalties if not using localStorage */\n\tinitialRoyalties?: Royalty[];\n\t/** Whether to show validation badge */\n\tshowValidation?: boolean;\n\t/** Default marketplace address for first royalty */\n\tmarketplaceAddress?: string;\n}\n\n// Add id to Royalty type for stable keys\ntype RoyaltyWithId = Royalty & { id: number };\n\nexport function RoyaltyEditor({\n\tstorageKey = \"bigblocks-collection-royalties\",\n\tmaxPercentage = 0.07,\n\tonChange,\n\tinitialRoyalties = [],\n\tshowValidation = true,\n\tmarketplaceAddress,\n}: RoyaltyEditorProps) {\n\t// Initialize with marketplace royalty if provided\n\tconst getDefaultRoyalties = () => {\n\t\tif (marketplaceAddress && initialRoyalties.length === 0) {\n\t\t\treturn [\n\t\t\t\t{\n\t\t\t\t\ttype: RoytaltyType.Address,\n\t\t\t\t\tdestination: marketplaceAddress,\n\t\t\t\t\tpercentage: \"0.01\", // 1% default\n\t\t\t\t},\n\t\t\t];\n\t\t}\n\t\treturn initialRoyalties;\n\t};\n\n\tconst [royalties, setRoyalties] = useState<RoyaltyWithId[]>(() =>\n\t\tgetDefaultRoyalties().map((r, i) => ({ ...r, id: i + 1 })),\n\t);\n\tconst [nextId, setNextId] = useState(royalties.length + 1);\n\n\t// Load from localStorage on mount\n\tuseEffect(() => {\n\t\tif (storageKey && typeof window !== \"undefined\") {\n\t\t\tconst stored = localStorage.getItem(storageKey);\n\t\t\tif (stored) {\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = JSON.parse(stored);\n\t\t\t\t\tconst royaltiesWithIds = parsed.map((r: Royalty, i: number) => ({\n\t\t\t\t\t\t...r,\n\t\t\t\t\t\tid: i + 1,\n\t\t\t\t\t}));\n\t\t\t\t\tsetRoyalties(royaltiesWithIds);\n\t\t\t\t\tsetNextId(royaltiesWithIds.length + 1);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(\"Failed to parse stored royalties:\", error);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [storageKey]);\n\n\t// Save to localStorage and notify parent\n\tconst updateRoyalties = useCallback(\n\t\t(newRoyalties: RoyaltyWithId[]) => {\n\t\t\tsetRoyalties(newRoyalties);\n\t\t\t// Strip id before calling onChange\n\t\t\tonChange?.(newRoyalties.map(({ id, ...rest }) => rest));\n\n\t\t\tif (storageKey && typeof window !== \"undefined\") {\n\t\t\t\tlocalStorage.setItem(\n\t\t\t\t\tstorageKey,\n\t\t\t\t\tJSON.stringify(newRoyalties.map(({ id, ...rest }) => rest)),\n\t\t\t\t);\n\t\t\t}\n\t\t},\n\t\t[onChange, storageKey],\n\t);\n\n\t// Add new royalty recipient\n\tconst addRoyalty = useCallback(() => {\n\t\tupdateRoyalties([\n\t\t\t...royalties,\n\t\t\t{\n\t\t\t\tid: nextId,\n\t\t\t\ttype: RoytaltyType.Address,\n\t\t\t\tdestination: \"\",\n\t\t\t\tpercentage: \"0\",\n\t\t\t},\n\t\t]);\n\t\tsetNextId(nextId + 1);\n\t}, [royalties, updateRoyalties, nextId]);\n\n\t// Remove royalty recipient\n\tconst removeRoyalty = useCallback(\n\t\t(id: number) => {\n\t\t\tconst royaltyToRemove = royalties.find((r) => r.id === id);\n\t\t\t// Don't allow removing the first (marketplace) royalty if it exists\n\t\t\tif (\n\t\t\t\troyaltyToRemove &&\n\t\t\t\tmarketplaceAddress &&\n\t\t\t\troyaltyToRemove.destination === marketplaceAddress &&\n\t\t\t\troyalties.findIndex((r) => r.id === id) === 0\n\t\t\t) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tupdateRoyalties(royalties.filter((r) => r.id !== id));\n\t\t},\n\t\t[royalties, marketplaceAddress, updateRoyalties],\n\t);\n\n\t// Update specific royalty field\n\tconst updateRoyalty = useCallback(\n\t\t(id: number, field: keyof Royalty, value: string | RoytaltyType) => {\n\t\t\tconst newRoyalties = [...royalties];\n\t\t\tconst index = newRoyalties.findIndex((r) => r.id === id);\n\t\t\tif (index === -1) return;\n\n\t\t\tconst currentRoyalty = newRoyalties[index];\n\n\t\t\tif (field === \"type\") {\n\t\t\t\tnewRoyalties[index] = {\n\t\t\t\t\t...currentRoyalty,\n\t\t\t\t\ttype: value as RoytaltyType,\n\t\t\t\t};\n\t\t\t} else if (field === \"percentage\") {\n\t\t\t\t// Convert percentage input to decimal format\n\t\t\t\tconst percentValue = Number.parseFloat(value || \"0\") / 100;\n\t\t\t\t// Clamp to max percentage\n\t\t\t\tconst clamped = Math.min(percentValue, maxPercentage);\n\t\t\t\tnewRoyalties[index] = {\n\t\t\t\t\t...currentRoyalty,\n\t\t\t\t\tpercentage: clamped.toFixed(4),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tnewRoyalties[index] = {\n\t\t\t\t\t...currentRoyalty,\n\t\t\t\t\t[field]: value as string,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tupdateRoyalties(newRoyalties);\n\t\t},\n\t\t[royalties, maxPercentage, updateRoyalties],\n\t);\n\n\t// Calculate total royalty percentage\n\tconst totalPercentage = royalties.reduce(\n\t\t(sum, r) => sum + Number.parseFloat(r.percentage || \"0\"),\n\t\t0,\n\t);\n\tconst isValid = totalPercentage <= maxPercentage;\n\n\treturn (\n\t\t<div className=\"flex flex-col gap-4\">\n\t\t\t<div className=\"flex justify-between items-center\">\n\t\t\t\t<p className=\"text-lg font-medium\">Royalty Recipients</p>\n\t\t\t\t<Button size=\"default\" variant=\"secondary\" onClick={addRoyalty}>\n\t\t\t\t\t<PlusIcon />\n\t\t\t\t\tAdd Recipient\n\t\t\t\t</Button>\n\t\t\t</div>\n\n\t\t\t<div className=\"flex flex-col gap-3\">\n\t\t\t\t{royalties.map((royalty) => {\n\t\t\t\t\tconst isMarketplace = !!(\n\t\t\t\t\t\troyalty.id === 1 &&\n\t\t\t\t\t\tmarketplaceAddress &&\n\t\t\t\t\t\troyalty.destination === marketplaceAddress\n\t\t\t\t\t);\n\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={royalty.id} className=\"flex flex-col gap-2\">\n\t\t\t\t\t\t\t<div className=\"flex gap-2 items-end\">\n\t\t\t\t\t\t\t\t<div className=\"flex flex-col gap-1 w-[140px]\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">Type</span>\n\t\t\t\t\t\t\t\t\t<Select\n\t\t\t\t\t\t\t\t\t\tvalue={royalty.type}\n\t\t\t\t\t\t\t\t\t\tonValueChange={(value) =>\n\t\t\t\t\t\t\t\t\t\t\tupdateRoyalty(royalty.id, \"type\", value as RoytaltyType)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdisabled={!!isMarketplace}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t\t<SelectValue />\n\t\t\t\t\t\t\t\t\t\t</SelectTrigger>\n\t\t\t\t\t\t\t\t\t\t<SelectContent>\n\t\t\t\t\t\t\t\t\t\t\t<SelectItem value={RoytaltyType.Address}>\n\t\t\t\t\t\t\t\t\t\t\t\tAddress\n\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t\t<SelectItem value={RoytaltyType.Paymail}>\n\t\t\t\t\t\t\t\t\t\t\t\tPaymail\n\t\t\t\t\t\t\t\t\t\t\t</SelectItem>\n\t\t\t\t\t\t\t\t\t\t</SelectContent>\n\t\t\t\t\t\t\t\t\t</Select>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<div className=\"flex flex-col gap-1 flex-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{royalty.type === RoytaltyType.Paymail\n\t\t\t\t\t\t\t\t\t\t\t? \"Paymail\"\n\t\t\t\t\t\t\t\t\t\t\t: \"Address\"}\n\t\t\t\t\t\t\t\t\t\t{isMarketplace && \" (Marketplace)\"}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\tplaceholder={\n\t\t\t\t\t\t\t\t\t\t\troyalty.type === RoytaltyType.Paymail\n\t\t\t\t\t\t\t\t\t\t\t\t? \"user@example.com\"\n\t\t\t\t\t\t\t\t\t\t\t\t: \"1Bitcoin...\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tvalue={royalty.destination}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\tupdateRoyalty(royalty.id, \"destination\", e.target.value)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tdisabled={!!isMarketplace}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<div className=\"flex flex-col gap-1 w-[100px]\">\n\t\t\t\t\t\t\t\t\t<span className=\"text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\tPercentage\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t<Input\n\t\t\t\t\t\t\t\t\t\tplaceholder=\"1\"\n\t\t\t\t\t\t\t\t\t\tvalue={(\n\t\t\t\t\t\t\t\t\t\t\tNumber.parseFloat(royalty.percentage || \"0\") * 100\n\t\t\t\t\t\t\t\t\t\t).toFixed(2)}\n\t\t\t\t\t\t\t\t\t\tonChange={(e) =>\n\t\t\t\t\t\t\t\t\t\t\tupdateRoyalty(royalty.id, \"percentage\", e.target.value)\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\tmin=\"0\"\n\t\t\t\t\t\t\t\t\t\tmax={maxPercentage * 100}\n\t\t\t\t\t\t\t\t\t\tstep=\"0.1\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\t\t\tsize=\"default\"\n\t\t\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\t\t\tonClick={() => removeRoyalty(royalty.id)}\n\t\t\t\t\t\t\t\t\tdisabled={isMarketplace}\n\t\t\t\t\t\t\t\t\tclassName=\"text-destructive hover:text-destructive\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<MinusIcon />\n\t\t\t\t\t\t\t\t</Button>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\n\t\t\t{royalties.length === 0 && (\n\t\t\t\t<p className=\"text-sm text-muted-foreground text-center p-4\">\n\t\t\t\t\tNo royalties configured. Add recipients to receive royalties on\n\t\t\t\t\tsecondary sales.\n\t\t\t\t</p>\n\t\t\t)}\n\n\t\t\t{showValidation && royalties.length > 0 && (\n\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<span className=\"text-sm font-medium\">Total Royalties:</span>\n\t\t\t\t\t<Badge variant={isValid ? \"default\" : \"destructive\"}>\n\t\t\t\t\t\t{(totalPercentage * 100).toFixed(2)}% /{\" \"}\n\t\t\t\t\t\t{(maxPercentage * 100).toFixed(0)}%\n\t\t\t\t\t</Badge>\n\t\t\t\t</div>\n\t\t\t)}\n\n\t\t\t<p className=\"text-xs text-muted-foreground\">\n\t\t\t\tRoyalties are paid to recipients on secondary sales. Maximum total:{\" \"}\n\t\t\t\t{(maxPercentage * 100).toFixed(0)}%\n\t\t\t</p>\n\t\t</div>\n\t);\n}\n", "target": "<%- config.aliases.components %>/royaltyeditor.tsx" } ] }