|
| 1 | +// Copyright (c) 2025 IOTA Stiftung |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +import { Info, Loader } from '@iota/apps-ui-icons'; |
| 5 | +import { |
| 6 | + Button, |
| 7 | + ButtonType, |
| 8 | + Dialog, |
| 9 | + DialogBody, |
| 10 | + DialogContent, |
| 11 | + DialogPosition, |
| 12 | + Header, |
| 13 | + InfoBox, |
| 14 | + InfoBoxStyle, |
| 15 | + InfoBoxType, |
| 16 | + LoadingIndicator, |
| 17 | + VisualAssetCard, |
| 18 | +} from '@iota/apps-ui-kit'; |
| 19 | +import { useCurrentAccount, useIotaClient, useSignAndExecuteTransaction } from '@iota/dapp-kit'; |
| 20 | +import { isSubname } from '@iota/iota-names-sdk'; |
| 21 | +import { useMutation, useQueryClient } from '@tanstack/react-query'; |
| 22 | +import { useState } from 'react'; |
| 23 | + |
| 24 | +import { |
| 25 | + NameRecordData, |
| 26 | + NameUpdate, |
| 27 | + queryKey, |
| 28 | + useNameRecord, |
| 29 | + useRegistrationNfts, |
| 30 | + useUpdateNameTransaction, |
| 31 | +} from '@/hooks'; |
| 32 | +import { useGetVisualAssets } from '@/hooks/useGetVisualAssets'; |
| 33 | +import { normalizeNameInput } from '@/lib/utils/format/formatNames'; |
| 34 | +import { getNameObject } from '@/lib/utils/names'; |
| 35 | +import { BrandedAssets } from '@/public/icons'; |
| 36 | + |
| 37 | +interface PersonalizeAvatarDialogProps { |
| 38 | + setOpen: (bool: boolean) => void; |
| 39 | + name: string; |
| 40 | +} |
| 41 | +export function PersonalizeAvatarDialog({ setOpen, name }: PersonalizeAvatarDialogProps) { |
| 42 | + const account = useCurrentAccount(); |
| 43 | + const iotaClient = useIotaClient(); |
| 44 | + const queryClient = useQueryClient(); |
| 45 | + const address = useCurrentAccount()?.address ?? ''; |
| 46 | + |
| 47 | + const { data: nameRecordData } = useNameRecord(name); |
| 48 | + const { data: subnamesOwned } = useRegistrationNfts('subname'); |
| 49 | + const { data: visualAssets, isLoading } = useGetVisualAssets(address); |
| 50 | + |
| 51 | + const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null); |
| 52 | + |
| 53 | + const nameRecord = nameRecordData as |
| 54 | + | Extract<NameRecordData, { type: 'unavailable' }> |
| 55 | + | undefined; |
| 56 | + const isNameSubname = nameRecord?.nameRecord ? isSubname(nameRecord.nameRecord.name) : null; |
| 57 | + const cleanName = normalizeNameInput(name); |
| 58 | + const updates: NameUpdate[] = []; |
| 59 | + |
| 60 | + if (selectedAssetId && selectedAssetId !== nameRecord?.nameRecord.avatar && nameRecord) { |
| 61 | + const nftId = isNameSubname |
| 62 | + ? getNameObject(subnamesOwned ?? [], nameRecord.nameRecord.name) |
| 63 | + : nameRecord.nameRecord.nftId; |
| 64 | + if (nftId) { |
| 65 | + updates.push({ |
| 66 | + type: 'set-avatar', |
| 67 | + nftId, |
| 68 | + avatarNftId: selectedAssetId, |
| 69 | + }); |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + const { data: updateTransaction } = useUpdateNameTransaction({ |
| 74 | + address: account?.address || '', |
| 75 | + updates, |
| 76 | + }); |
| 77 | + |
| 78 | + const { mutateAsync: signAndExecuteTransaction, isPending: isSigning } = |
| 79 | + useSignAndExecuteTransaction(); |
| 80 | + |
| 81 | + const { mutate: saveAvatar, isPending: isSaving } = useMutation({ |
| 82 | + async mutationFn() { |
| 83 | + if (!updateTransaction) return; |
| 84 | + const tx = await signAndExecuteTransaction({ transaction: updateTransaction }); |
| 85 | + |
| 86 | + await iotaClient.waitForTransaction({ digest: tx.digest }); |
| 87 | + |
| 88 | + queryClient.invalidateQueries({ queryKey: queryKey.nameRecord(name) }); |
| 89 | + }, |
| 90 | + onSuccess() { |
| 91 | + setOpen(false); |
| 92 | + }, |
| 93 | + }); |
| 94 | + |
| 95 | + function handleSelectAsset() { |
| 96 | + if (!selectedAssetId || !updateTransaction) return; |
| 97 | + saveAvatar(); |
| 98 | + } |
| 99 | + return ( |
| 100 | + <Dialog open onOpenChange={setOpen}> |
| 101 | + <DialogContent |
| 102 | + customWidth="w-full max-w-[90vw] md:max-w-[50vw] xl:max-w-[60vw]" |
| 103 | + position={DialogPosition.Right} |
| 104 | + > |
| 105 | + <Header |
| 106 | + title="Personalize Avatar" |
| 107 | + titleCentered |
| 108 | + onClose={() => setOpen(false)} |
| 109 | + onBack={() => setOpen(false)} |
| 110 | + /> |
| 111 | + |
| 112 | + <DialogBody> |
| 113 | + <div className="flex flex-col gap-md items-center"> |
| 114 | + <div className="flex flex-col gap-md items-center"> |
| 115 | + <BrandedAssets className="w-12 h-12" /> |
| 116 | + <div className="flex flex-col gap-xs text-center"> |
| 117 | + <span className="text-title-md text-names-neutral-92"> |
| 118 | + @{cleanName} |
| 119 | + </span> |
| 120 | + <span className="text-body-md text-names-neutral-70"> |
| 121 | + Use an NFT to personalize your avatar |
| 122 | + </span> |
| 123 | + </div> |
| 124 | + </div> |
| 125 | + {isLoading ? ( |
| 126 | + <div className="flex items-center justify-center w-full h-full py-lg"> |
| 127 | + <LoadingIndicator text="Loading Assets..." /> |
| 128 | + </div> |
| 129 | + ) : !visualAssets || visualAssets?.length === 0 ? ( |
| 130 | + <div className="flex items-center justify-center w-full py-lg"> |
| 131 | + <InfoBox |
| 132 | + title="No Eligible NFTs" |
| 133 | + supportingText="There are no NFTs in your wallet that can be used as an avatar" |
| 134 | + icon={<Info />} |
| 135 | + type={InfoBoxType.Warning} |
| 136 | + style={InfoBoxStyle.Default} |
| 137 | + /> |
| 138 | + </div> |
| 139 | + ) : ( |
| 140 | + <div className="max-h-[400px] w-full grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-md"> |
| 141 | + {visualAssets.map((asset) => { |
| 142 | + const isSelected = selectedAssetId === asset.objectId; |
| 143 | + |
| 144 | + return ( |
| 145 | + <div |
| 146 | + key={asset.objectId} |
| 147 | + className={`rounded-xl p-[1px] transition-all ${ |
| 148 | + isSelected |
| 149 | + ? 'bg-names-gradient-primary' |
| 150 | + : 'bg-transparent' |
| 151 | + }`} |
| 152 | + > |
| 153 | + <div className="bg-names-neutral-6 rounded-xl"> |
| 154 | + <VisualAssetCard |
| 155 | + src={asset.display?.data?.image_url || ''} |
| 156 | + altText={asset.display?.data?.name || 'NFT'} |
| 157 | + isHoverable |
| 158 | + onClick={() => |
| 159 | + setSelectedAssetId(asset.objectId) |
| 160 | + } |
| 161 | + /> |
| 162 | + </div> |
| 163 | + </div> |
| 164 | + ); |
| 165 | + })} |
| 166 | + </div> |
| 167 | + )} |
| 168 | + </div> |
| 169 | + </DialogBody> |
| 170 | + |
| 171 | + <div className="flex w-full flex-row justify-center gap-2 px-md--rs pb-md--rs pt-md--rs"> |
| 172 | + <Button |
| 173 | + type={ButtonType.Secondary} |
| 174 | + text="Cancel" |
| 175 | + onClick={() => setOpen(false)} |
| 176 | + fullWidth |
| 177 | + /> |
| 178 | + <Button |
| 179 | + type={ButtonType.Primary} |
| 180 | + text={isSaving || isSigning ? 'Uploading...' : 'Upload Avatar'} |
| 181 | + onClick={handleSelectAsset} |
| 182 | + disabled={isSaving || isSigning || !selectedAssetId} |
| 183 | + fullWidth |
| 184 | + icon={ |
| 185 | + isSaving || isSigning ? ( |
| 186 | + <Loader className="animate-spin" data-testid="loading-indicator" /> |
| 187 | + ) : null |
| 188 | + } |
| 189 | + /> |
| 190 | + </div> |
| 191 | + </DialogContent> |
| 192 | + </Dialog> |
| 193 | + ); |
| 194 | +} |
0 commit comments