|
| 1 | +import { |
| 2 | + Box, |
| 3 | + Button, |
| 4 | + Flex, |
| 5 | + Grid, |
| 6 | + HStack, |
| 7 | + Text, |
| 8 | + VStack, |
| 9 | +} from "@chakra-ui/react"; |
| 10 | +import { Link } from "@remix-run/react"; |
| 11 | +import { OrderDirection, TransferFractionToken_OrderBy } from "gql/graphql"; |
| 12 | +import { useNamesByAddresses } from "hooks/useENS"; |
| 13 | +import { useGetTransferFractionTokens } from "hooks/useFractionToken"; |
| 14 | +import { type FC, useMemo, useState } from "react"; |
| 15 | +import { ipfs2https } from "utils/ipfs"; |
| 16 | +import { abbreviateAddress } from "utils/wallet"; |
| 17 | +import { UserIcon } from "../icon/UserIcon"; |
| 18 | + |
| 19 | +interface Props { |
| 20 | + treeId: string; |
| 21 | + limit?: number; |
| 22 | +} |
| 23 | + |
| 24 | +interface FriendshipData { |
| 25 | + user1: string; |
| 26 | + user2: string; |
| 27 | + totalAmount: number; |
| 28 | + transactionCount: number; |
| 29 | +} |
| 30 | + |
| 31 | +interface FriendshipItemProps { |
| 32 | + treeId: string; |
| 33 | + friendship: FriendshipData; |
| 34 | + rank: number; |
| 35 | + sortBy: SortType; |
| 36 | +} |
| 37 | + |
| 38 | +const FriendshipItem: FC<FriendshipItemProps> = ({ |
| 39 | + treeId, |
| 40 | + friendship, |
| 41 | + rank, |
| 42 | + sortBy, |
| 43 | +}) => { |
| 44 | + const addresses = useMemo(() => { |
| 45 | + return [friendship.user1, friendship.user2]; |
| 46 | + }, [friendship.user1, friendship.user2]); |
| 47 | + |
| 48 | + const getRankColors = (rank: number) => { |
| 49 | + switch (rank) { |
| 50 | + case 1: |
| 51 | + return { |
| 52 | + bg: "yellow.400", |
| 53 | + color: "white", |
| 54 | + cardBg: "yellow.200", |
| 55 | + borderColor: "yellow.300", |
| 56 | + statsColor: "yellow.700", |
| 57 | + statsFontSize: "xl", |
| 58 | + }; // Gold |
| 59 | + case 2: |
| 60 | + return { |
| 61 | + bg: "gray.400", |
| 62 | + color: "white", |
| 63 | + cardBg: "gray.200", |
| 64 | + borderColor: "gray.300", |
| 65 | + statsColor: "gray.700", |
| 66 | + statsFontSize: "lg", |
| 67 | + }; // Silver |
| 68 | + case 3: |
| 69 | + return { |
| 70 | + bg: "orange.600", |
| 71 | + color: "white", |
| 72 | + cardBg: "orange.200", |
| 73 | + borderColor: "orange.300", |
| 74 | + statsColor: "orange.700", |
| 75 | + statsFontSize: "md", |
| 76 | + }; // Bronze |
| 77 | + default: |
| 78 | + return { |
| 79 | + bg: "purple.500", |
| 80 | + color: "white", |
| 81 | + cardBg: "purple.50", |
| 82 | + borderColor: "purple.200", |
| 83 | + statsColor: "purple.600", |
| 84 | + statsFontSize: "md", |
| 85 | + }; // Default purple |
| 86 | + } |
| 87 | + }; |
| 88 | + |
| 89 | + const rankColors = getRankColors(rank); |
| 90 | + |
| 91 | + const { names } = useNamesByAddresses(addresses); |
| 92 | + |
| 93 | + const user1Name = useMemo(() => { |
| 94 | + return names?.[0]?.[0]; |
| 95 | + }, [names]); |
| 96 | + |
| 97 | + const user2Name = useMemo(() => { |
| 98 | + return names?.[1]?.[0]; |
| 99 | + }, [names]); |
| 100 | + |
| 101 | + return ( |
| 102 | + <Box |
| 103 | + h="70px" |
| 104 | + py={3} |
| 105 | + px={4} |
| 106 | + w="full" |
| 107 | + borderColor={rankColors.borderColor} |
| 108 | + position="relative" |
| 109 | + bgColor={rankColors.cardBg} |
| 110 | + borderRadius={8} |
| 111 | + overflow="hidden" |
| 112 | + borderBottomColor={rankColors.borderColor} |
| 113 | + > |
| 114 | + <Grid |
| 115 | + gridTemplateColumns="60px 1fr 100px" |
| 116 | + justifyContent="space-between" |
| 117 | + alignItems="center" |
| 118 | + height="100%" |
| 119 | + > |
| 120 | + {/* Rank */} |
| 121 | + <Flex |
| 122 | + alignItems="center" |
| 123 | + justifyContent="center" |
| 124 | + w="40px" |
| 125 | + h="40px" |
| 126 | + borderRadius="full" |
| 127 | + bgColor={rankColors.bg} |
| 128 | + color={rankColors.color} |
| 129 | + fontWeight="bold" |
| 130 | + fontSize="lg" |
| 131 | + > |
| 132 | + {rank} |
| 133 | + </Flex> |
| 134 | + |
| 135 | + {/* Users */} |
| 136 | + <Flex |
| 137 | + alignItems="center" |
| 138 | + justifyContent="center" |
| 139 | + gap={3} |
| 140 | + px={2} |
| 141 | + minW="0" |
| 142 | + > |
| 143 | + <Link to={`/${treeId}/member/${friendship.user1}`}> |
| 144 | + <VStack gap={1} minW="0" alignItems="center"> |
| 145 | + <UserIcon |
| 146 | + size="32px" |
| 147 | + userImageUrl={ipfs2https(user1Name?.text_records?.avatar)} |
| 148 | + /> |
| 149 | + <Text |
| 150 | + fontSize="xs" |
| 151 | + fontWeight="medium" |
| 152 | + color="gray.700" |
| 153 | + maxW="70px" |
| 154 | + textAlign="center" |
| 155 | + overflow="hidden" |
| 156 | + whiteSpace="nowrap" |
| 157 | + textOverflow="ellipsis" |
| 158 | + > |
| 159 | + {user1Name?.name || abbreviateAddress(friendship.user1)} |
| 160 | + </Text> |
| 161 | + </VStack> |
| 162 | + </Link> |
| 163 | + |
| 164 | + <Text |
| 165 | + color="purple.500" |
| 166 | + fontWeight="bold" |
| 167 | + fontSize="lg" |
| 168 | + alignSelf="flex-start" |
| 169 | + mt={2} |
| 170 | + > |
| 171 | + ⟷ |
| 172 | + </Text> |
| 173 | + |
| 174 | + <Link to={`/${treeId}/member/${friendship.user2}`}> |
| 175 | + <VStack gap={1} minW="0" alignItems="center"> |
| 176 | + <UserIcon |
| 177 | + size="32px" |
| 178 | + userImageUrl={ipfs2https(user2Name?.text_records?.avatar)} |
| 179 | + /> |
| 180 | + <Text |
| 181 | + fontSize="xs" |
| 182 | + fontWeight="medium" |
| 183 | + color="gray.700" |
| 184 | + maxW="70px" |
| 185 | + textAlign="center" |
| 186 | + overflow="hidden" |
| 187 | + whiteSpace="nowrap" |
| 188 | + textOverflow="ellipsis" |
| 189 | + > |
| 190 | + {user2Name?.name || abbreviateAddress(friendship.user2)} |
| 191 | + </Text> |
| 192 | + </VStack> |
| 193 | + </Link> |
| 194 | + </Flex> |
| 195 | + |
| 196 | + {/* Stats */} |
| 197 | + <Box textAlign="center" w="100px"> |
| 198 | + {sortBy === "totalAmount" ? ( |
| 199 | + <> |
| 200 | + <Text |
| 201 | + fontSize={rankColors.statsFontSize} |
| 202 | + fontWeight="bold" |
| 203 | + color={rankColors.statsColor} |
| 204 | + > |
| 205 | + {friendship.totalAmount} |
| 206 | + </Text> |
| 207 | + <Text fontSize="xs" color="gray.500"> |
| 208 | + {friendship.transactionCount}回 |
| 209 | + </Text> |
| 210 | + </> |
| 211 | + ) : ( |
| 212 | + <> |
| 213 | + <Text |
| 214 | + fontSize={rankColors.statsFontSize} |
| 215 | + fontWeight="bold" |
| 216 | + color={rankColors.statsColor} |
| 217 | + > |
| 218 | + {friendship.transactionCount}回 |
| 219 | + </Text> |
| 220 | + <Text fontSize="xs" color="gray.500"> |
| 221 | + {friendship.totalAmount}pt |
| 222 | + </Text> |
| 223 | + </> |
| 224 | + )} |
| 225 | + </Box> |
| 226 | + </Grid> |
| 227 | + </Box> |
| 228 | + ); |
| 229 | +}; |
| 230 | + |
| 231 | +type SortType = "totalAmount" | "transactionCount"; |
| 232 | + |
| 233 | +/** |
| 234 | + * フレンドシップランキングを表示するコンポーネント |
| 235 | + * 二人の間でのアシストクレジット総量と取引回数を表示 |
| 236 | + */ |
| 237 | +export const FriendshipRanking: FC<Props> = ({ treeId, limit = 500 }) => { |
| 238 | + const [sortBy, setSortBy] = useState<SortType>("totalAmount"); |
| 239 | + const { data } = useGetTransferFractionTokens({ |
| 240 | + where: { |
| 241 | + workspaceId: treeId, |
| 242 | + }, |
| 243 | + orderBy: TransferFractionToken_OrderBy.BlockTimestamp, |
| 244 | + orderDirection: OrderDirection.Desc, |
| 245 | + first: limit, |
| 246 | + }); |
| 247 | + |
| 248 | + const friendshipData = useMemo(() => { |
| 249 | + if (!data?.transferFractionTokens) return []; |
| 250 | + |
| 251 | + // ユーザーペア間のデータを集計 |
| 252 | + const pairMap = new Map<string, FriendshipData>(); |
| 253 | + |
| 254 | + for (const token of data.transferFractionTokens) { |
| 255 | + const user1 = token.from.toLowerCase(); |
| 256 | + const user2 = token.to.toLowerCase(); |
| 257 | + |
| 258 | + // アドレスをソートしてペアキーを作成(順序に関係なく同じペアとして扱う) |
| 259 | + const sortedPair = [user1, user2].sort(); |
| 260 | + const pairKey = `${sortedPair[0]}-${sortedPair[1]}`; |
| 261 | + |
| 262 | + if (pairMap.has(pairKey)) { |
| 263 | + const existing = pairMap.get(pairKey); |
| 264 | + if (existing) { |
| 265 | + existing.totalAmount += Number(token.amount); |
| 266 | + existing.transactionCount += 1; |
| 267 | + } |
| 268 | + } else { |
| 269 | + pairMap.set(pairKey, { |
| 270 | + user1: sortedPair[0], |
| 271 | + user2: sortedPair[1], |
| 272 | + totalAmount: Number(token.amount), |
| 273 | + transactionCount: 1, |
| 274 | + }); |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + // ソート |
| 279 | + return Array.from(pairMap.values()) |
| 280 | + .sort((a, b) => { |
| 281 | + if (sortBy === "totalAmount") { |
| 282 | + return b.totalAmount - a.totalAmount; |
| 283 | + } |
| 284 | + return b.transactionCount - a.transactionCount; |
| 285 | + }) |
| 286 | + .slice(0, 20); // 上位20ペアまで表示 |
| 287 | + }, [data, sortBy]); |
| 288 | + |
| 289 | + if ( |
| 290 | + !data?.transferFractionTokens || |
| 291 | + data.transferFractionTokens.length === 0 |
| 292 | + ) { |
| 293 | + return ( |
| 294 | + <Box p={8} textAlign="center" color="gray.500"> |
| 295 | + <Text>フレンドシップデータがありません</Text> |
| 296 | + </Box> |
| 297 | + ); |
| 298 | + } |
| 299 | + |
| 300 | + return ( |
| 301 | + <VStack gap={3}> |
| 302 | + <Box w="full" mb={2}> |
| 303 | + <HStack justifyContent="center" gap={2}> |
| 304 | + <Button |
| 305 | + size="sm" |
| 306 | + variant={sortBy === "totalAmount" ? "solid" : "outline"} |
| 307 | + bgColor={sortBy === "totalAmount" ? "blue.400" : "transparent"} |
| 308 | + onClick={() => setSortBy("totalAmount")} |
| 309 | + > |
| 310 | + 総交換量順 |
| 311 | + </Button> |
| 312 | + <Button |
| 313 | + size="sm" |
| 314 | + variant={sortBy === "transactionCount" ? "solid" : "outline"} |
| 315 | + bgColor={sortBy === "transactionCount" ? "blue.400" : "transparent"} |
| 316 | + onClick={() => setSortBy("transactionCount")} |
| 317 | + > |
| 318 | + 取引回数順 |
| 319 | + </Button> |
| 320 | + </HStack> |
| 321 | + </Box> |
| 322 | + {friendshipData.map((friendship, index) => ( |
| 323 | + <FriendshipItem |
| 324 | + treeId={treeId} |
| 325 | + key={`friendship_${friendship.user1}_${friendship.user2}`} |
| 326 | + friendship={friendship} |
| 327 | + rank={index + 1} |
| 328 | + sortBy={sortBy} |
| 329 | + /> |
| 330 | + ))} |
| 331 | + </VStack> |
| 332 | + ); |
| 333 | +}; |
0 commit comments