Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send Fraction Token #216

Merged
merged 3 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions pkgs/frontend/app/components/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { HStack, IconButton, Text } from "@chakra-ui/react";
import { useNavigate } from "@remix-run/react";
import { ReactNode, useCallback } from "react";
import { FaChevronLeft } from "react-icons/fa6";

interface Props {
title: string | ReactNode;
backLink?: string | (() => void);
}

export const PageHeader: React.FC<Props> = ({ title, backLink }) => {
const navigate = useNavigate();

const handleBack = useCallback(() => {
if (backLink && typeof backLink === "string") {
navigate(backLink);
} else if (backLink && typeof backLink === "function") {
backLink();
} else {
navigate(-1);
}
}, [backLink]);

return (
<HStack>
<IconButton
size="sm"
onClick={handleBack}
bgColor="transparent"
color="black"
>
<FaChevronLeft />
</IconButton>
<Text>{title}</Text>
</HStack>
);
};
2 changes: 1 addition & 1 deletion pkgs/frontend/app/components/common/CommonIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Box, Image } from "@chakra-ui/react";

interface CommonIconProps {
imageUrl: string | undefined;
size: number | "full";
size: number | `${number}px` | "full";
fallbackIconComponent?: ReactNode;
}

Expand Down
1 change: 1 addition & 0 deletions pkgs/frontend/app/components/common/CommonInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const CommonInput = ({
width="100%"
onChange={onChange}
borderColor="gray.800"
backgroundColor="white"
/>
);
};
2 changes: 1 addition & 1 deletion pkgs/frontend/app/components/icon/RoleIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ipfs2https } from "utils/ipfs";

interface RoleIconProps {
roleImageUrl?: string;
size?: number | "full";
size?: number | `${number}px` | "full";
}

export const RoleIcon = ({ roleImageUrl, size = "full" }: RoleIconProps) => {
Expand Down
2 changes: 1 addition & 1 deletion pkgs/frontend/app/components/icon/UserIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CommonIcon } from "../common/CommonIcon";

interface UserIconProps {
userImageUrl: string | undefined;
size?: number | "full";
size?: number | `${number}px` | "full";
}

export const UserIcon = ({ userImageUrl, size = "full" }: UserIconProps) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import {
Box,
Float,
HStack,
Input,
List,
Text,
VStack,
} from "@chakra-ui/react";
import { useParams } from "@remix-run/react";
import {
useActiveWalletIdentity,
useAddressesByNames,
useNamesByAddresses,
} from "hooks/useENS";
import {
useBalanceOfFractionToken,
useFractionToken,
} from "hooks/useFractionToken";
import { useTreeInfo } from "hooks/useHats";
import { NameData, TextRecords } from "namestone-sdk";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { FaArrowRight } from "react-icons/fa6";
import { ipfs2https } from "utils/ipfs";
import { abbreviateAddress } from "utils/wallet";
import { Address } from "viem";
import { BasicButton } from "~/components/BasicButton";
import { CommonInput } from "~/components/common/CommonInput";
import { RoleIcon } from "~/components/icon/RoleIcon";
import { UserIcon } from "~/components/icon/UserIcon";
import { PageHeader } from "~/components/PageHeader";
import { Field } from "~/components/ui/field";

const AssistCreditSend: FC = () => {
const { treeId, hatId, address } = useParams();
const me = useActiveWalletIdentity();
const balanceOfToken = useBalanceOfFractionToken(
me.identity?.address as Address,
address as Address,
BigInt(hatId!)
);

// 送信先取得
const tree = useTreeInfo(Number(treeId));
const [searchText, setSearchText] = useState<string>("");

const members = useMemo(() => {
if (!tree || !tree.hats) return [];
return tree.hats
.filter((h) => h.levelAtLocalTree && h.levelAtLocalTree >= 0)
.map((h) => h.wearers)
.flat()
.filter((w) => w)
.map((w) => w!.id);
}, [tree]);

const { names: defaultNames } = useNamesByAddresses(members);
const { names, fetchNames } = useNamesByAddresses();
const { addresses, fetchAddresses } = useAddressesByNames();

const isSearchAddress = useMemo(() => {
return searchText.startsWith("0x") && searchText.length === 42;
}, [searchText]);

useEffect(() => {
if (isSearchAddress) {
fetchNames([searchText]);
} else {
fetchAddresses([searchText]);
}
}, [searchText, isSearchAddress]);

const users = useMemo(() => {
if (!searchText) {
const unresolvedMembers = Array.from(
new Set(
members.filter((m) => !defaultNames[0].find((n) => n.address === m))
)
);
return [
...defaultNames,
...unresolvedMembers.map((m) => [
{
address: m,
name: "",
domain: "",
text_records: {
avatar: "",
} as TextRecords,
},
]),
];
}

return isSearchAddress ? names : addresses;
}, [defaultNames, names, addresses, isSearchAddress]);

// 送信先選択後
const [receiver, setReceiver] = useState<NameData>();
const [amount, setAmount] = useState<number>(0);

const { sendFractionToken } = useFractionToken();
const send = useCallback(async () => {
if (!receiver || !hatId || !me) return;
await sendFractionToken({
hatId: BigInt(hatId),
account: me.identity?.address as Address,
to: receiver.address as Address,
amount: BigInt(amount),
});
}, [sendFractionToken, receiver, amount, hatId, address]);

return (
<Box>
<PageHeader
title={
receiver
? `${receiver.name || `${abbreviateAddress(receiver.address)}に送信`}`
: "アシストクレジット送信"
}
backLink={
receiver &&
(() => {
setReceiver(undefined);
setAmount(0);
})
}
/>

<HStack my={2}>
<RoleIcon size="50px" />
<Text>掃除当番(残高: {balanceOfToken?.toLocaleString()})</Text>
</HStack>

{!receiver ? (
<>
<Field label="ユーザー名 or ウォレットアドレスで検索">
<CommonInput
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
}}
placeholder="ユーザー名 or ウォレットアドレス"
/>
</Field>

<List.Root listStyle="none" my={10} gap={3}>
{users?.flat().map((user, index) => (
<List.Item key={index} onClick={() => setReceiver(user)}>
<HStack>
<UserIcon
userImageUrl={ipfs2https(user.text_records?.avatar)}
size={10}
/>
<Text lineBreak="anywhere">
{user.name
? `${user.name} (${user.address.slice(0, 6)}...${user.address.slice(-4)})`
: user.address}
</Text>
</HStack>
</List.Item>
))}
</List.Root>
</>
) : (
<>
<Field
label="送信量"
mt="calc(50vh - 230px)"
alignItems="center"
justifyContent="center"
>
<Input
p={2}
pb={4}
fontSize="60px"
size="2xl"
border="none"
borderBottom="2px solid"
borderRadius="0"
w="auto"
type="number"
textAlign="center"
min={0}
max={9999}
style={{
WebkitAppearance: "none",
}}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
/>
</Field>

<Float
placement="bottom-center"
mb="7vh"
width="100%"
display="flex"
flexDirection="column"
alignItems="center"
>
<HStack columnGap={3} mb={4}>
<Box textAlign="center">
<UserIcon
size={10}
userImageUrl={ipfs2https(me.identity?.text_records?.avatar)}
/>
<Text fontSize="xs">{me.identity?.name}</Text>
</Box>
<VStack textAlign="center">
<Text>{amount}</Text>
<FaArrowRight size="20px" />
</VStack>
<Box>
<UserIcon
size={10}
userImageUrl={ipfs2https(receiver.text_records?.avatar)}
/>
<Text fontSize="xs">
{receiver.name || abbreviateAddress(receiver.address)}
</Text>
</Box>
</HStack>
<BasicButton onClick={send}>送信</BasicButton>
</Float>
</>
)}
</Box>
);
};

export default AssistCreditSend;
13 changes: 10 additions & 3 deletions pkgs/frontend/app/routes/api.namestone.$action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,32 @@ const domain = "toban.eth";

export const loader: LoaderFunction = async ({ request, params }) => {
const { action } = params;
const searchParams = new URL(request.url).searchParams;

switch (action) {
case "resolve-names":
const addresses = new URL(request.url).searchParams.get("addresses");
const addresses = searchParams.get("addresses");
if (!addresses) return Response.json([]);

const resolvedNames = await Promise.all(
addresses.split(",").map((address) => ns.getNames({ domain, address }))
);
return Response.json(resolvedNames);
case "resolve-addresses":
const names = new URL(request.url).searchParams.get("names");
const names = searchParams.get("names");
if (!names) return Response.json([]);

const exactMatch = searchParams.get("exact_match");

const resolvedAddresses = await Promise.all(
names
.split(",")
.map((name) =>
ns.searchNames({ domain, name, exact_match: 1 as any })
ns.searchNames({
domain,
name,
exact_match: exactMatch === "true" ? 1 : (0 as any),
})
)
);
return Response.json(resolvedAddresses);
Expand Down
2 changes: 1 addition & 1 deletion pkgs/frontend/app/routes/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const Login: FC = () => {
const names = useMemo(() => {
return userName ? [userName] : [];
}, [userName]);
const { addresses } = useAddressesByNames(names);
const { addresses } = useAddressesByNames(names, true);

const availableName = useMemo(() => {
if (!userName) return false;
Expand Down
4 changes: 2 additions & 2 deletions pkgs/frontend/hooks/useENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ export const useNamesByAddresses = (addresses?: string[]) => {
return { names, fetchNames };
};

export const useAddressesByNames = (names?: string[]) => {
export const useAddressesByNames = (names?: string[], exactMatch?: boolean) => {
const [addresses, setAddresses] = useState<NameData[][]>([]);

const fetchAddresses = useCallback(async (resolveNames: string[]) => {
try {
const { data } = await axios.get("/api/namestone/resolve-addresses", {
params: { names: resolveNames.join(",") },
params: { names: resolveNames.join(","), exact_match: exactMatch },
});
setAddresses(data);
return data as NameData[][];
Expand Down
Loading
Loading