diff --git a/package-lock.json b/package-lock.json index 02bf252b..62ebf52f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "graphql": "^16.6.0", "graphql-request": "^5.2.0", "graphql-tag": "^2.12.6", + "json2csv": "^6.0.0-alpha.2", "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", @@ -54,6 +55,7 @@ }, "devDependencies": { "@faker-js/faker": "^7.6.0", + "@types/json2csv": "^5.0.3", "@types/jsonwebtoken": "^9.0.1", "@types/lodash.debounce": "^4.0.7", "@types/lodash.get": "^4.4.7", @@ -3009,6 +3011,11 @@ "@stablelib/wipe": "^1.0.1" } }, + "node_modules/@streamparser/json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", + "integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==" + }, "node_modules/@swc/helpers": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz", @@ -3138,6 +3145,15 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/json2csv": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-5.0.3.tgz", + "integrity": "sha512-ZJEv6SzhPhgpBpxZU4n/TZekbZqI4EcyXXRwms1lAITG2kIAtj85PfNYafUOY1zy8bWs5ujaub0GU4copaA0sw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -8289,6 +8305,31 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, + "node_modules/json2csv/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "engines": { + "node": ">= 6" + } + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -15045,6 +15086,11 @@ "@stablelib/wipe": "^1.0.1" } }, + "@streamparser/json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", + "integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==" + }, "@swc/helpers": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz", @@ -15138,6 +15184,15 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "@types/json2csv": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/json2csv/-/json2csv-5.0.3.tgz", + "integrity": "sha512-ZJEv6SzhPhgpBpxZU4n/TZekbZqI4EcyXXRwms1lAITG2kIAtj85PfNYafUOY1zy8bWs5ujaub0GU4copaA0sw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -19108,6 +19163,23 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, + "json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "requires": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "dependencies": { + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==" + } + } + }, "json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", diff --git a/package.json b/package.json index 0343569d..f4eaeec7 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" }, "dependencies": { + "@amplitude/analytics-browser": "^1.9.1", "@dynamic-labs/sdk-react": "^0.15.16", "@dynamic-labs/wagmi-connector": "^0.15.16", - "@amplitude/analytics-browser": "^1.9.1", "@ethersproject/hash": "^5.7.0", "@headlessui/react": "^1.7.10", "@heroicons/react": "^2.0.14", @@ -41,6 +41,7 @@ "graphql": "^16.6.0", "graphql-request": "^5.2.0", "graphql-tag": "^2.12.6", + "json2csv": "^6.0.0-alpha.2", "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", @@ -74,6 +75,7 @@ }, "devDependencies": { "@faker-js/faker": "^7.6.0", + "@types/json2csv": "^5.0.3", "@types/jsonwebtoken": "^9.0.1", "@types/lodash.debounce": "^4.0.7", "@types/lodash.get": "^4.4.7", diff --git a/pages/api/v1/tokenTransfer/export.ts b/pages/api/v1/tokenTransfer/export.ts new file mode 100644 index 00000000..a6df4e83 --- /dev/null +++ b/pages/api/v1/tokenTransfer/export.ts @@ -0,0 +1,82 @@ +import db from "db" +import { parse } from "json2csv" +import { isAuthenticated } from "lib/api/auth/isAuthenticated" +import { NextApiRequest, NextApiResponse } from "next" +import { + alchemyFetcher, + TransferDirection, +} from "../../../../src/hooks/useAssetTransfers" + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + // should only be able to export if you are authed + await isAuthenticated(req, res) + + const { query } = req + const { + safeAddress: safeAddressQuery, + chainId: chainIdQuery, + filter: filterQuery, + } = query as { + safeAddress: string + chainId: string + filter: string + } + + const databaseTransfers = await db.tokenTransfer.findMany({ + where: { + chainId: parseInt(chainIdQuery), + terminalAddress: safeAddressQuery, + }, + }) + + const alchemyToTransfers = await alchemyFetcher([ + safeAddressQuery, + parseInt(chainIdQuery), + TransferDirection.INBOUND, + ]) + + const alchemyFromTransfers = await alchemyFetcher([ + safeAddressQuery, + parseInt(chainIdQuery), + TransferDirection.OUTBOUND, + ]) + + const alchemyTransfers = + filterQuery === TransferDirection.ALL + ? [...alchemyToTransfers, ...alchemyFromTransfers] + : filterQuery === TransferDirection.INBOUND + ? alchemyToTransfers + : alchemyFromTransfers + + const joinedData = [] as any[] + alchemyTransfers.forEach((alchemyItem: any) => { + const databaseItem = databaseTransfers.find( + (item: any) => item.txHash === alchemyItem.hash, + ) + if (databaseItem) { + let databaseMeta = databaseItem?.data as { + note: string + category: string + } + joinedData.push({ + ...alchemyItem, + message: databaseMeta.note ?? "", + category: databaseMeta.category ?? "", + }) + } else { + joinedData.push(alchemyItem) + } + }) + + const csv = parse(joinedData) + + res.setHeader("Content-Type", "text/csv") + res.setHeader( + "Content-Disposition", + `attachment; filename=${chainIdQuery}-${safeAddressQuery}-${filterQuery}.csv`, + ) + res.status(200).send(csv) +} diff --git a/pages/api/v1/tokenTransfer/index.ts b/pages/api/v1/tokenTransfer/index.ts new file mode 100644 index 00000000..ded94b1b --- /dev/null +++ b/pages/api/v1/tokenTransfer/index.ts @@ -0,0 +1,74 @@ +import db from "db" +import { isAuthenticated } from "lib/api/auth/isAuthenticated" +import { NextApiRequest, NextApiResponse } from "next" + +export async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method, query, body } = req + const { safeAddress: safeAddressQuery, chainId: chainIdQuery } = query as { + safeAddress: string + chainId: string + } + + switch (method) { + case "PUT": + const { note, category, txHash } = body as { + note: string + category: string + txHash: string + } + // no try/catch since isAuthenticated will return the response + await isAuthenticated(req, res) + + try { + const transfer = await db.tokenTransfer.upsert({ + where: { + txHash: txHash, + }, + update: { + data: { + note: note, + category: category, + }, + }, + create: { + txHash: txHash, + chainId: parseInt(chainIdQuery), + terminalAddress: safeAddressQuery, + data: { + note: note, + category: category, + }, + }, + }) + res.status(200).json(transfer) + } catch (err) { + console.error("Error creating terminal", err) + res.statusCode = 500 + return res.end(JSON.stringify("Error creating terminal")) + } + break + + case "GET": + try { + const transfers = await db.tokenTransfer.findMany({ + where: { + chainId: parseInt(chainIdQuery), + terminalAddress: safeAddressQuery, + }, + }) + + res.status(200).json(transfers) + } catch (err) { + console.error("Error fetching transfers", err) + res.statusCode = 500 + return res.end(JSON.stringify("Error fetching transfers")) + } + break + + default: + res.setHeader("Allow", ["GET", "PUT"]) + res.status(405).end(`Method ${method} Not Allowed`) + } +} + +export default handler diff --git a/prisma/migrations/20230405161319_adding_token_transfer_table/migration.sql b/prisma/migrations/20230405161319_adding_token_transfer_table/migration.sql new file mode 100644 index 00000000..a8a39c8b --- /dev/null +++ b/prisma/migrations/20230405161319_adding_token_transfer_table/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "TokenTransfer" ( + "id" TEXT NOT NULL, + "chainId" INTEGER NOT NULL, + "terminalAddress" TEXT NOT NULL, + "data" JSONB NOT NULL, + + CONSTRAINT "TokenTransfer_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "TokenTransfer" ADD CONSTRAINT "TokenTransfer_chainId_terminalAddress_fkey" FOREIGN KEY ("chainId", "terminalAddress") REFERENCES "Terminal"("chainId", "safeAddress") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20230406023450_adding_tx_hash/migration.sql b/prisma/migrations/20230406023450_adding_tx_hash/migration.sql new file mode 100644 index 00000000..7a3b71dd --- /dev/null +++ b/prisma/migrations/20230406023450_adding_tx_hash/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[txHash]` on the table `TokenTransfer` will be added. If there are existing duplicate values, this will fail. + - Added the required column `txHash` to the `TokenTransfer` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TokenTransfer" ADD COLUMN "txHash" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "TokenTransfer_txHash_key" ON "TokenTransfer"("txHash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e90094d..6d9ad777 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,7 @@ model Terminal { requests Request[] automations Automation[] invoices Invoice[] + tokenTransfers TokenTransfer[] @@unique([chainId, safeAddress]) } @@ -136,6 +137,16 @@ model Invoice { @@unique([terminalAddress, chainId, number(sort: Desc)]) } +model TokenTransfer { + id String @id @default(uuid()) + chainId Int + terminalAddress String + txHash String @unique + data Json + + terminal Terminal @relation(fields: [chainId, terminalAddress], references: [chainId, safeAddress]) +} + enum ActivityVariant { CREATE_REQUEST CREATE_AND_APPROVE_REQUEST diff --git a/src/components/assets/AssetTransfersTab.tsx b/src/components/assets/AssetTransfersTab.tsx index 13130188..d10830f7 100644 --- a/src/components/assets/AssetTransfersTab.tsx +++ b/src/components/assets/AssetTransfersTab.tsx @@ -1,4 +1,22 @@ +import BottomDrawer from "@ui/BottomDrawer" +import Breakpoint from "@ui/Breakpoint" +import { Button } from "@ui/Button" +import RightSlider from "@ui/RightSlider" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/Select" import QrCodeEmptyState from "components/emptyStates/QrCodeEmptyState" +import { toChecksumAddress } from "lib/utils/toChecksumAddress" +import { convertGlobalId } from "models/terminal/utils" +import { useRouter } from "next/router" +import { useState } from "react" +import { Controller, FieldValues, useForm } from "react-hook-form" +import { useIsSigner } from "../../../src/hooks/useIsSigner" +import { useUpsertTokenTransfer } from "../../hooks/tokenTransfer/useUpsertTokenTransfer" import { TransferDirection, TransferItem, @@ -7,6 +25,9 @@ import { import { timeSince } from "../../lib/utils" import networks from "../../lib/utils/networks" import { TokenType } from "../../models/token/types" +import { TokenTransferVariant } from "../../models/tokenTransfer/types" +import TextareaWithLabel from "../form/TextareaWithLabel" +import { ArrowDownRight, ArrowUpRight } from "../icons" import { Address } from "../ui/Address" import { Avatar } from "../ui/Avatar" import { Hyperlink } from "../ui/Hyperlink" @@ -16,49 +37,246 @@ type TransactionProps = { hash: string value: string date: string + from: string + to: string address: string + terminalAddress: string + note?: string + category?: string } const TransactionItem = ({ hash, value, date, + from, + to, address, chainId, + terminalAddress, + note, + category, }: TransactionProps) => { + const isSigner = useIsSigner({ address: terminalAddress, chainId }) + const [sliderOpen, setSliderOpen] = useState(false) const blockExplorer = (networks as Record)[String(chainId)] .explorer - return ( -
-
-
- -
-
-
-
-

- {date} - {" ยท "} -

+ + const { upsertTokenTransfer } = useUpsertTokenTransfer( + chainId, + terminalAddress, + ) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + } = useForm({ + mode: "all", + defaultValues: { + description: note, + category: category, + } as FieldValues, + }) + + const onSubmit = async (data: any) => { + await upsertTokenTransfer({ + txHash: hash, + note: data.description, + category: data.category, + }) + setSliderOpen(false) + } + + const onError = (errors: any) => { + console.log(errors) + } + + const TxContent = () => { + return ( +
+
+
{value}
+
+
{date}
+
+
+
+
+
From
+
+
+
+
To
+
+
+
+
+ +
+ +
+
Category
+ ( + + )} + /> +
+
+
+
-
-

{value}

-
+ + ) + } + + return ( + <> + + {(isMobile) => { + if (isMobile) { + return ( + + + + ) + } else { + return ( + + + + ) + } + }} + + + + {(isMobile) => { + if (isMobile) { + return ( +
{ + setSliderOpen(true) + }} + > +
+
+ +
+
+ + {(note ?? "Sent | Received").substring(0, 40)} + {note && note.length > 40 && "..."} + +
{date}
+
+
+ +
+ {toChecksumAddress(from) === + toChecksumAddress(terminalAddress) ? ( + + ) : ( + + )} + {value} +
+
+ ) + } else { + return ( +
{ + setSliderOpen(true) + }} + > +
+
+
+ +
+ + {( + note ?? + (toChecksumAddress(from) === + toChecksumAddress(terminalAddress) + ? "Sent" + : "Received") + ).substring(0, 40)} + {note && note.length > 40 && "..."} + +
+
+
+ {toChecksumAddress(from) === + toChecksumAddress(terminalAddress) ? ( + + ) : ( + + )} + {value} +
+
{date}
+
{category ?? "Uncategorized"}
+
+ ) + } + }} +
+ ) } const formatAssetValue = (tx: TransferItem): string => { + const minVal = 0.00001 switch (tx.category) { case TokenType.ERC20: + if (tx.amount && tx.amount < minVal) { + return `< ${minVal} ` + tx.symbol + } return tx.amount + " " + tx.symbol case TokenType.COIN: + if (tx.amount && tx.amount < minVal) { + return `< ${minVal} ETH` + } return tx.amount + " ETH" case TokenType.ERC721: case TokenType.ERC1155: @@ -75,6 +293,10 @@ export const AssetTransfersTab = ({ chainId: number direction: TransferDirection }) => { + const router = useRouter() + const { address: safeAddress } = convertGlobalId( + router.query.chainNameAndSafeAddress as string, + ) const { data } = useAssetTransfers(address, chainId, direction) if (!data?.length) { @@ -95,16 +317,29 @@ export const AssetTransfersTab = ({ return (
- {data?.map((tx: TransferItem) => ( - - ))} +
+
Description
+
Amount
+
Date
+
Category
+
+
+ {data?.map((tx: TransferItem) => ( + + ))} +
) } diff --git a/src/components/core/TabBar.tsx b/src/components/core/TabBar.tsx index af4e7d9d..a27f8c57 100644 --- a/src/components/core/TabBar.tsx +++ b/src/components/core/TabBar.tsx @@ -14,12 +14,14 @@ export const TabBar = ({ showBorder = false, defaultValue, options, + actionElement, children, }: { className?: string style: "tab" | "filter" showBorder?: boolean defaultValue: string + actionElement?: React.ReactNode options: { value: string; label: string }[] children: React.ReactNode }) => { @@ -62,22 +64,25 @@ export const TabBar = ({ ) : ( // style = "filter" - - {options.map((option) => ( - - {option.label} - - ))} - +
+ + {options.map((option) => ( + + {option.label} + + ))} + + {actionElement} +
)} {children} diff --git a/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx b/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx index 4bdd1001..1e33c22f 100644 --- a/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx +++ b/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx @@ -1,16 +1,23 @@ import { TabBar } from "../TabBar" export enum TerminalAssetsHistoryFilter { + ALL = "all", SENT = "sent", RECEIVED = "received", } export const TerminalAssetsHistoryFilterBar = ({ + actionElement, children, }: { + actionElement?: React.ReactNode children: React.ReactNode }) => { const options = [ + { + value: TerminalAssetsHistoryFilter.ALL, + label: "All", + }, { value: TerminalAssetsHistoryFilter.SENT, label: "Sent", @@ -26,6 +33,7 @@ export const TerminalAssetsHistoryFilterBar = ({ style="filter" defaultValue={TerminalAssetsHistoryFilter.SENT} options={options} + actionElement={actionElement} > {children} diff --git a/src/components/icons/ArrowDownRight.tsx b/src/components/icons/ArrowDownRight.tsx new file mode 100644 index 00000000..60c09708 --- /dev/null +++ b/src/components/icons/ArrowDownRight.tsx @@ -0,0 +1,30 @@ +import { cn } from "lib/utils" +import { icon, IconProps } from "./utils" + +export const ArrowDownRight = ({ + size = "base", + color, + className, +}: IconProps & { className?: string }) => { + return ( + + + + + ) +} diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index bcc2553c..3d353029 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -1,3 +1,4 @@ +export { ArrowDownRight } from "./ArrowDownRight" export { ArrowLeft } from "./ArrowLeft" export { ArrowSplit } from "./ArrowSplit" export { ArrowUpRight } from "./ArrowUpRight" diff --git a/src/components/pages/assets/components/AssetPageContent.tsx b/src/components/pages/assets/components/AssetPageContent.tsx index 8671bc37..7c2d0f51 100644 --- a/src/components/pages/assets/components/AssetPageContent.tsx +++ b/src/components/pages/assets/components/AssetPageContent.tsx @@ -1,5 +1,11 @@ +import { useDynamicContext } from "@dynamic-labs/sdk-react" +import { Button } from "@ui/Button" import { TabsContent } from "@ui/Tabs" +import axios from "axios" +import { convertGlobalId } from "models/terminal/utils" +import { useRouter } from "next/router" import { TransferDirection } from "../../../../../src/hooks/useAssetTransfers" +import { useIsSigner } from "../../../../../src/hooks/useIsSigner" import { Terminal } from "../../../../../src/models/terminal/types" import { AssetTransfersTab } from "../../../assets/AssetTransfersTab" import { CurrentAssetsTab } from "../../../assets/CurrentAssetsTab" @@ -13,24 +19,74 @@ import { } from "../../../core/TabBars/TerminalAssetsTabBar" const TerminalAssetsHistoryTab = ({ terminal }: { terminal: Terminal }) => { + const { authToken } = useDynamicContext() + + const router = useRouter() + const { address, chainId } = convertGlobalId( + router.query.chainNameAndSafeAddress as string, + ) as { address: string; chainId: number } + + const isSigner = useIsSigner({ address, chainId }) + + const ExportButton = ( + + ) + return ( - - - - - - - - +
+ + + + + + + + + + + +
) } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 547554c4..040b86f6 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,7 +1,7 @@ import { cva, VariantProps } from "class-variance-authority" import React, { ButtonHTMLAttributes } from "react" -export const buttonStyles = cva("relative rounded text-center", { +export const buttonStyles = cva("relative rounded text-center self-start", { variants: { variant: { primary: "text-black bg-violet", diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index e4ba6568..7bc8f3b6 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -79,7 +79,7 @@ const SelectItem = React.forwardRef< {children} - +

Selected

diff --git a/src/hooks/tokenTransfer/useUpsertTokenTransfer.ts b/src/hooks/tokenTransfer/useUpsertTokenTransfer.ts new file mode 100644 index 00000000..962d006b --- /dev/null +++ b/src/hooks/tokenTransfer/useUpsertTokenTransfer.ts @@ -0,0 +1,51 @@ +import { useDynamicContext } from "@dynamic-labs/sdk-react" +import axios from "axios" +import useSWRMutation from "swr/mutation" + +export const useUpsertTokenTransfer = ( + chainId: number, + safeAddress: string, +) => { + const { authToken } = useDynamicContext() + + const fetcher = async (url: string, { arg }: { arg: any }) => { + const { note, category, txHash } = arg + if (!txHash) { + throw Error( + `Missing args in "upsertTokenTransfer". Args specified - txHash: ${txHash}`, + ) + } + + try { + const response = await axios.put( + url, + { + txHash, + note, + category, + }, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + ) + if (response.status === 200) { + return response.data + } + } catch (err) { + console.log("err:", err) + } + } + + const { + trigger: upsertTokenTransfer, + isMutating, + error, + } = useSWRMutation( + `/api/v1/tokenTransfer?safeAddress=${safeAddress}&chainId=${chainId}`, + fetcher, + ) + + return { isMutating, upsertTokenTransfer, error } +} diff --git a/src/hooks/useAssetTransfers.ts b/src/hooks/useAssetTransfers.ts index 99285e84..e2a239bf 100644 --- a/src/hooks/useAssetTransfers.ts +++ b/src/hooks/useAssetTransfers.ts @@ -1,10 +1,12 @@ import axios from "axios" import { alchemyChainIdToChainName } from "lib/constants" +import { useEffect, useState } from "react" import useSWR from "swr" import { getSplitWithdrawEvents } from "../models/automation/queries/getSplitWithdrawEvents" import { TokenType } from "../models/token/types" export enum TransferDirection { + ALL = "all", INBOUND = "inbound", OUTBOUND = "outbound", WITHDRAW_EVENT = "withdraw-event", @@ -32,9 +34,13 @@ export type TransferItem = { metadata: { blockTimestamp: string } + data?: { + note: string + category: string + } } -const alchemyFetcher = async ([address, chainId, direction]: [ +export const alchemyFetcher = async ([address, chainId, direction]: [ string, number, TransferDirection, @@ -97,6 +103,17 @@ const alchemyFetcher = async ([address, chainId, direction]: [ } } +const databaseFetcher = async (url: string) => { + try { + const response = await axios.get(url) + + return response.data + } catch (err) { + console.error("err:", err) + return null + } +} + export const useAssetTransfers = ( address: string, chainId: number, @@ -106,10 +123,60 @@ export const useAssetTransfers = ( direction === TransferDirection.WITHDRAW_EVENT ? getSplitWithdrawEvents : alchemyFetcher - const { isLoading, data, error } = useSWR( - [address, chainId, direction], + + const { data: toData } = useSWR( + [address, chainId, TransferDirection.OUTBOUND], + fetcher, + ) + const { data: fromData } = useSWR( + [address, chainId, TransferDirection.INBOUND], fetcher, ) - return { isLoading, data, error } + const { data: databaseData } = useSWR( + `/api/v1/tokenTransfer?safeAddress=${address}&chainId=${chainId}`, + databaseFetcher, + ) + + const [alchemyData, setAlchemyData] = useState([] as any[]) + useEffect(() => { + if (!toData || !fromData) return + switch (direction) { + case TransferDirection.ALL: + if (toData && fromData) { + setAlchemyData([...toData, ...fromData]) + } + break + case TransferDirection.INBOUND: + setAlchemyData(fromData) + break + + case TransferDirection.OUTBOUND: + setAlchemyData(toData) + break + } + }, [toData, fromData]) + + const [data, setData] = useState([] as any[]) + + useEffect(() => { + if (!alchemyData || !databaseData) return + const joinedData = [] as any[] + alchemyData.forEach((alchemyItem: any) => { + const databaseItem = databaseData.find( + (item: any) => item.txHash === alchemyItem.hash, + ) + if (databaseItem) { + joinedData.push({ + ...alchemyItem, + ...databaseItem, + }) + } else { + joinedData.push(alchemyItem) + } + }) + setData(joinedData) + }, [alchemyData, databaseData]) + + return { data } } diff --git a/src/hooks/useIsSigner.ts b/src/hooks/useIsSigner.ts index 9b1c861f..b93caca4 100644 --- a/src/hooks/useIsSigner.ts +++ b/src/hooks/useIsSigner.ts @@ -14,6 +14,7 @@ export const useIsSigner = ({ address, chainId, }) + const isSigner = usePermissionsStore((state) => state.isSigner) const setIsSigner = usePermissionsStore((state) => state.setIsSigner) const { primaryWallet } = useDynamicContext() diff --git a/src/models/tokenTransfer/types.ts b/src/models/tokenTransfer/types.ts new file mode 100644 index 00000000..86608329 --- /dev/null +++ b/src/models/tokenTransfer/types.ts @@ -0,0 +1,27 @@ +import { TokenTransfer as PrismaTokenTransfer } from "@prisma/client" +import { Transfer } from "../request/types" + +type TokenTransferMetadata = { + recipient: string + transfer: Transfer + note?: string + category: TokenTransferVariant +} + +export enum TokenTransferVariant { + DONATION = "DONATION", + "NFT SALE" = "NFT SALE", + "CONTRIBUTOR PAYMENT" = "CONTRIBUTOR PAYMENT", + INCOME = "INCOME", + REIMBURSEMENT = "REIMBURSEMENT", + GRANT = "GRANT", + SWAP = "SWAP", + AIRDROP = "AIRDROP", + "STAKING REWARD" = "STAKING REWARD", + EQUITY = "EQUITY", + OTHER = "OTHER", +} + +export type TokenTransfer = PrismaTokenTransfer & { + data: TokenTransferMetadata +}