From 939a6c13a3eec3e43b9df7d242b5388e015b90e6 Mon Sep 17 00:00:00 2001 From: wraeth-eth <104132113+wraeth-eth@users.noreply.github.com> Date: Mon, 30 Oct 2023 16:03:37 +1100 Subject: [PATCH] beneficiary (#130) ### Description Adds a beneficiary input ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/pinTSPPZHK80UCzWAYgo/a2e75391-bb34-4138-b5c5-14a6ddd3056b.png) --- src/components/EthereumAddress.tsx | 38 +++-- src/components/Input.tsx | 44 +++--- .../Project/components/ProjectPayForm.tsx | 132 +++++++++++++++++- src/components/Spinner.tsx | 42 +++--- src/components/ui/HoverCard.tsx | 2 +- 5 files changed, 202 insertions(+), 56 deletions(-) diff --git a/src/components/EthereumAddress.tsx b/src/components/EthereumAddress.tsx index 401fd195..f8556120 100644 --- a/src/components/EthereumAddress.tsx +++ b/src/components/EthereumAddress.tsx @@ -11,6 +11,7 @@ import { Skeleton } from './ui/Skeleton' export type EthereumAddressProps = { className?: string address: `0x${string}` | undefined + showEnsIcon?: boolean showEnsLoading?: boolean ensDisabled?: boolean truncateTo?: number @@ -19,6 +20,7 @@ export type EthereumAddressProps = { export const EthereumAddress: React.FC = ({ className, address, + showEnsIcon, ensDisabled = false, showEnsLoading = false, truncateTo, @@ -35,28 +37,42 @@ export const EthereumAddress: React.FC = ({ return truncateEthAddress({ address, truncateTo }) }, [address, ensDisabled, ensName, truncateTo]) + const ensAvatarIcon = useMemo( + () => + address ? ( + {`Avatar + ) : null, + [address, ensName], + ) + return ( + {showEnsIcon && ensAvatarIcon ? ( +
+ {ensAvatarIcon} +
+ ) : null} {formattedAddress}
- +
- {address ? ( - {`Avatar + {ensAvatarIcon ? ( +
+ {ensAvatarIcon} +
) : ( )} diff --git a/src/components/Input.tsx b/src/components/Input.tsx index f3fa2434..3aea1e16 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -16,33 +16,41 @@ const Input = React.forwardRef( ) => { if (prefix || suffix) { return ( -
-
- {prefix} -
+
+ {prefix && ( +
+ {prefix} +
+ )} -
- {suffix} -
+ {suffix && ( +
+ {suffix} +
+ )}
) } diff --git a/src/components/Project/components/ProjectPayForm.tsx b/src/components/Project/components/ProjectPayForm.tsx index efdc2edc..d282cd00 100644 --- a/src/components/Project/components/ProjectPayForm.tsx +++ b/src/components/Project/components/ProjectPayForm.tsx @@ -1,4 +1,7 @@ +import { EthereumAddress } from '@/components/EthereumAddress' import { Input } from '@/components/Input' +import { Link } from '@/components/Link' +import { Spinner } from '@/components/Spinner' import { EthereumIconFilled } from '@/components/icon/EthereumIconFilled' import { Button } from '@/components/ui/Button' import { @@ -10,25 +13,35 @@ import { FormLabel, FormMessage, } from '@/components/ui/Form' +import { useIpfsFilePicker } from '@/hooks/useIpfsFilePicker/useIpfsFilePicker' import { useJbProject } from '@/hooks/useJbProject' +import { publicClient } from '@/lib/viem/publicClient' import { ChevronDownIcon, EnvelopeIcon, + PencilSquareIcon, PhotoIcon, QuestionMarkCircleIcon, XCircleIcon, } from '@heroicons/react/24/outline' import { zodResolver } from '@hookform/resolvers/zod' import { formatEther } from 'juice-hooks' -import { PropsWithChildren, useCallback, useMemo, useState } from 'react' +import Image from 'next/image' +import { + ChangeEventHandler, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { useForm } from 'react-hook-form' import { twMerge } from 'tailwind-merge' -import { parseEther } from 'viem' +import { isAddress, parseEther } from 'viem' +import { useAccount, useEnsName } from 'wagmi' import { z } from 'zod' import { useProjectPay } from '../providers/ProjectPayContext' -import { useIpfsFilePicker } from '@/hooks/useIpfsFilePicker/useIpfsFilePicker' -import { Link } from '@/components/Link' -import Image from 'next/image' const WEI = 1e-18 @@ -37,7 +50,13 @@ const formSchema = z.object({ .number() .min(WEI, 'Payment amount must be greater than 1e-18 (1 wei)'), // TODO: make more robust for eth addresses / ENS - beneficiary: z.string().min(2, 'Beneficiary must be at least 2 characters'), + beneficiary: z + .string() + .optional() + .refine(value => { + if (!value) return true + return isAddress(value) + }, 'Invalid wallet address'), email: z.string().email('Invalid email address').optional(), message: z.string().optional(), }) @@ -121,7 +140,7 @@ export const ProjectPayForm: React.FC = ({ className="mt-6" label="NFTs and rewards will be sent to" > - + )} /> @@ -135,6 +154,7 @@ export const ProjectPayForm: React.FC = ({ description="Enter email to receive confirmation & updates" > } suffix={ @@ -338,3 +358,101 @@ const ProjectPayMessageInput: React.FC = ({ ) } + +type ProjectPayBeneficiaryInputProps = {} & Omit< + React.InputHTMLAttributes, + 'prefix' +> + +const ProjectPayBeneficiaryInput: React.FC = ({ + className, + onChange: _onChange, + ...props +}) => { + const { address: ownerAddress } = useAccount() + + const currentAddress = useMemo(() => { + if (!props.value) return ownerAddress + if (!isAddress(props.value as string)) return ownerAddress + + return props.value as `0x${string}` + }, [ownerAddress, props.value]) + + const { data: currentEnsFromAddress } = useEnsName({ + address: currentAddress, + }) + const [isEditing, setIsEditing] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const inputRef = useRef(null) + + /** + * hijacks the onChange event to check if the value is an ENS name or address + */ + const onChange: ChangeEventHandler = useCallback( + async e => { + const value = e.target.value + _onChange?.(e) + if (isEnsName(value)) { + setIsLoading(true) + try { + const ensAddress = await publicClient.getEnsAddress({ name: value }) + if (!ensAddress) return + _onChange?.({ ...e, target: { ...e.target, value: ensAddress } }) + setIsEditing(false) + } catch (e) { + console.error(e) + } finally { + setIsLoading(false) + } + } else if (isAddress(value)) { + setIsEditing(false) + } + }, + [_onChange], + ) + + useEffect(() => { + if (!isEditing) return + inputRef.current?.focus() + }, [isEditing]) + + if (!isEditing) { + return ( +
+ {currentAddress ? ( + + ) : ( + <>Payer's address + )} + +
+ ) + } + + return ( + <> + : null + } + onBlur={() => setIsEditing(false)} + /> + + ) +} + +const isEnsName = (value: string) => { + return value.endsWith('.eth') +} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 000a515f..667b1c31 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -6,24 +6,28 @@ type SpinnerProps = { export const Spinner: React.FC = ({ className }) => { return ( - - - - +
+ + Loading... +
) } diff --git a/src/components/ui/HoverCard.tsx b/src/components/ui/HoverCard.tsx index 9cfcb5a6..114bc01e 100644 --- a/src/components/ui/HoverCard.tsx +++ b/src/components/ui/HoverCard.tsx @@ -16,7 +16,7 @@ const HoverCardContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 min-w-[256px] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className, )} {...props}