From 8b2bad90bc21cbfb2709807368a8ca0145503208 Mon Sep 17 00:00:00 2001 From: Michael Gingras Date: Wed, 5 Apr 2023 21:17:22 -0600 Subject: [PATCH 1/8] feat: a bit jank, but token annotations --- pages/api/v1/tokenTransfer/index.ts | 74 ++++++ .../migration.sql | 12 + .../migration.sql | 12 + prisma/schema.prisma | 11 + src/components/assets/AssetTransfersTab.tsx | 232 +++++++++++++++--- .../tokenTransfer/useUpsertTokenTransfer.ts | 51 ++++ src/hooks/useAssetTransfers.ts | 45 +++- src/models/tokenTransfer/types.ts | 27 ++ 8 files changed, 432 insertions(+), 32 deletions(-) create mode 100644 pages/api/v1/tokenTransfer/index.ts create mode 100644 prisma/migrations/20230405161319_adding_token_transfer_table/migration.sql create mode 100644 prisma/migrations/20230406023450_adding_tx_hash/migration.sql create mode 100644 src/hooks/tokenTransfer/useUpsertTokenTransfer.ts create mode 100644 src/models/tokenTransfer/types.ts 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..5dd3940c 100644 --- a/src/components/assets/AssetTransfersTab.tsx +++ b/src/components/assets/AssetTransfersTab.tsx @@ -1,4 +1,18 @@ +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 { convertGlobalId } from "models/terminal/utils" +import { useRouter } from "next/router" +import { useState } from "react" +import { Controller, FieldValues, useForm } from "react-hook-form" +import { useUpsertTokenTransfer } from "../../hooks/tokenTransfer/useUpsertTokenTransfer" import { TransferDirection, TransferItem, @@ -7,6 +21,8 @@ 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 { Address } from "../ui/Address" import { Avatar } from "../ui/Avatar" import { Hyperlink } from "../ui/Hyperlink" @@ -16,41 +32,161 @@ 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 [sliderOpen, setSliderOpen] = useState(false) const blockExplorer = (networks as Record)[String(chainId)] .explorer + + const { upsertTokenTransfer } = useUpsertTokenTransfer( + chainId, + terminalAddress, + ) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + watch, + } = useForm({ + mode: "all", // validate on all event handlers (onBlur, onChange, onSubmit) + defaultValues: { + description: note, + category, + } as FieldValues, + }) + + const onSubmit = async (data: any) => { + await upsertTokenTransfer({ + txHash: hash, + note: data.description, + category: data.category, + }) + } + + const onError = (errors: any) => { + console.log(errors) + } + return ( -
-
-
- -
-
-
-
-

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

- + <> + +
+
+
{value}
+
+
{date}
+ +
+
+
+
+
+
From
+
+
+
+
To
+
+
+
+
+ +
+ +
+
Category
+ ( + + )} + /> +
+
+
+
+
+
+
{ + setSliderOpen(true) + }} + > +
+
+
+ +
+ {note ?? "Sent | Received"} +
+
+
{value}
+
{date}
+
+ {category ?? "Uncategorized"} + {/* */}
-

{value}

-
+ ) } @@ -75,7 +211,24 @@ 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) + console.log(data) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + control, + watch, + } = useForm({ + mode: "all", // validate on all event handlers (onBlur, onChange, onSubmit) + defaultValues: {} as FieldValues, + }) if (!data?.length) { const title = "Deposit tokens" @@ -93,18 +246,39 @@ export const AssetTransfersTab = ({ ) } + const onSubmit = (data: any) => { + console.log(data) + } + + const onError = (errors: any) => { + console.log(errors) + } + return (
- {data?.map((tx: TransferItem) => ( - - ))} +
+
Description
+
Amount
+
Date
+
Category
+
+
+ {data?.map((tx: TransferItem) => ( + + ))} +
) } diff --git a/src/hooks/tokenTransfer/useUpsertTokenTransfer.ts b/src/hooks/tokenTransfer/useUpsertTokenTransfer.ts new file mode 100644 index 00000000..f8acc525 --- /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 (!note || !category || !txHash) { + throw Error( + `Missing args in "upsertTokenTransfer". Args specified - note: ${note}, category: ${category}, 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..752326e5 100644 --- a/src/hooks/useAssetTransfers.ts +++ b/src/hooks/useAssetTransfers.ts @@ -32,6 +32,10 @@ export type TransferItem = { metadata: { blockTimestamp: string } + data?: { + note: string + category: string + } } const alchemyFetcher = async ([address, chainId, direction]: [ @@ -97,6 +101,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 +121,34 @@ export const useAssetTransfers = ( direction === TransferDirection.WITHDRAW_EVENT ? getSplitWithdrawEvents : alchemyFetcher - const { isLoading, data, error } = useSWR( - [address, chainId, direction], - fetcher, + const { + isLoading, + data: alchemyData, + error, + } = useSWR([address, chainId, direction], fetcher) + + const { data: databaseData } = useSWR( + `/api/v1/tokenTransfer?safeAddress=${address}&chainId=${chainId}`, + databaseFetcher, ) + const data = [] as any[] + + if (alchemyData && databaseData) { + alchemyData.forEach((alchemyItem: any) => { + const databaseItem = databaseData.find( + (item: any) => item.txHash === alchemyItem.hash, + ) + if (databaseItem) { + data.push({ + ...alchemyItem, + ...databaseItem, + }) + } else { + data.push(alchemyItem) + } + }) + } + return { isLoading, data, error } } 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 +} From 00b144ebf4f83b93856850776eda4bd82fef9a22 Mon Sep 17 00:00:00 2001 From: Michael Gingras Date: Wed, 5 Apr 2023 21:23:15 -0600 Subject: [PATCH 2/8] default value for category --- src/components/assets/AssetTransfersTab.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/assets/AssetTransfersTab.tsx b/src/components/assets/AssetTransfersTab.tsx index 5dd3940c..18d37157 100644 --- a/src/components/assets/AssetTransfersTab.tsx +++ b/src/components/assets/AssetTransfersTab.tsx @@ -68,10 +68,10 @@ const TransactionItem = ({ control, watch, } = useForm({ - mode: "all", // validate on all event handlers (onBlur, onChange, onSubmit) + mode: "all", defaultValues: { description: note, - category, + category: category, } as FieldValues, }) @@ -129,7 +129,7 @@ const TransactionItem = ({ control={control} name="category" render={({ field: { onChange, ref } }) => ( - From 244e76f35e702f5b345103cdb11fcb6b50d13713 Mon Sep 17 00:00:00 2001 From: Michael Gingras Date: Wed, 5 Apr 2023 22:29:56 -0600 Subject: [PATCH 3/8] adding bottom drawer for mobile, and all tab --- src/components/assets/AssetTransfersTab.tsx | 158 ++++++++++-------- .../TerminalAssetsHistoryFilterBar.tsx | 5 + .../assets/components/AssetPageContent.tsx | 7 + src/hooks/useAssetTransfers.ts | 50 ++++-- 4 files changed, 141 insertions(+), 79 deletions(-) diff --git a/src/components/assets/AssetTransfersTab.tsx b/src/components/assets/AssetTransfersTab.tsx index 18d37157..276a94c9 100644 --- a/src/components/assets/AssetTransfersTab.tsx +++ b/src/components/assets/AssetTransfersTab.tsx @@ -1,3 +1,5 @@ +import BottomDrawer from "@ui/BottomDrawer" +import Breakpoint from "@ui/Breakpoint" import { Button } from "@ui/Button" import RightSlider from "@ui/RightSlider" import { @@ -87,77 +89,98 @@ const TransactionItem = ({ console.log(errors) } - return ( - <> - -
-
-
{value}
-
-
{date}
- -
-
-
-
-
-
From
-
-
-
-
To
-
-
+ const TxContent = () => { + return ( + +
+
{value}
+
+
{date}
+ +
+
+
+
+
+
From
+
-
- +
+
To
+
+
+
+ +
-
-
Category
- ( - - )} - /> -
-
-
- +
+
Category
+ ( + + )} + />
- - +
+
+ +
+ + ) + } + + return ( + <> + + {(isMobile) => { + if (isMobile) { + return ( + + + + ) + } else { + return ( + + + + ) + } + }} + +
{ @@ -217,7 +240,6 @@ export const AssetTransfersTab = ({ ) const { data } = useAssetTransfers(address, chainId, direction) - console.log(data) const { register, diff --git a/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx b/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx index 4bdd1001..3f1bedb0 100644 --- a/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx +++ b/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx @@ -1,6 +1,7 @@ import { TabBar } from "../TabBar" export enum TerminalAssetsHistoryFilter { + ALL = "all", SENT = "sent", RECEIVED = "received", } @@ -11,6 +12,10 @@ export const TerminalAssetsHistoryFilterBar = ({ children: React.ReactNode }) => { const options = [ + { + value: TerminalAssetsHistoryFilter.ALL, + label: "All", + }, { value: TerminalAssetsHistoryFilter.SENT, label: "Sent", diff --git a/src/components/pages/assets/components/AssetPageContent.tsx b/src/components/pages/assets/components/AssetPageContent.tsx index 8671bc37..a8b2b39d 100644 --- a/src/components/pages/assets/components/AssetPageContent.tsx +++ b/src/components/pages/assets/components/AssetPageContent.tsx @@ -16,6 +16,13 @@ const TerminalAssetsHistoryTab = ({ terminal }: { terminal: Terminal }) => { return ( + + + { + if (!toData || !fromData) return + switch (direction) { + case TransferDirection.ALL: + if (toData && fromData) { + setAlchemyData([...toData, ...fromData]) + } + break + case TransferDirection.INBOUND: + setAlchemyData(fromData) + break - if (alchemyData && databaseData) { + 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) { - data.push({ + joinedData.push({ ...alchemyItem, ...databaseItem, }) } else { - data.push(alchemyItem) + joinedData.push(alchemyItem) } }) - } + setData(joinedData) + }, [alchemyData, databaseData]) - return { isLoading, data, error } + return { data } } From 5874e5921b16cc39f9c0cb9d7e8baf94fb5b08ef Mon Sep 17 00:00:00 2001 From: Michael Gingras Date: Thu, 6 Apr 2023 13:00:15 -0600 Subject: [PATCH 4/8] ability to export transactions --- package-lock.json | 72 +++++++++++++++ package.json | 4 +- pages/api/v1/tokenTransfer/export.ts | 82 +++++++++++++++++ src/components/core/TabBar.tsx | 37 ++++---- .../TerminalAssetsHistoryFilterBar.tsx | 3 + .../assets/components/AssetPageContent.tsx | 89 ++++++++++++++----- src/components/ui/Button.tsx | 2 +- src/hooks/useAssetTransfers.ts | 2 +- 8 files changed, 249 insertions(+), 42 deletions(-) create mode 100644 pages/api/v1/tokenTransfer/export.ts 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/src/components/core/TabBar.tsx b/src/components/core/TabBar.tsx index af4e7d9d..5c410c27 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 3f1bedb0..1e33c22f 100644 --- a/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx +++ b/src/components/core/TabBars/TerminalAssetsHistoryFilterBar.tsx @@ -7,8 +7,10 @@ export enum TerminalAssetsHistoryFilter { } export const TerminalAssetsHistoryFilterBar = ({ + actionElement, children, }: { + actionElement?: React.ReactNode children: React.ReactNode }) => { const options = [ @@ -31,6 +33,7 @@ export const TerminalAssetsHistoryFilterBar = ({ style="filter" defaultValue={TerminalAssetsHistoryFilter.SENT} options={options} + actionElement={actionElement} > {children} diff --git a/src/components/pages/assets/components/AssetPageContent.tsx b/src/components/pages/assets/components/AssetPageContent.tsx index a8b2b39d..662444ab 100644 --- a/src/components/pages/assets/components/AssetPageContent.tsx +++ b/src/components/pages/assets/components/AssetPageContent.tsx @@ -1,4 +1,9 @@ +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 { Terminal } from "../../../../../src/models/terminal/types" import { AssetTransfersTab } from "../../../assets/AssetTransfersTab" @@ -13,31 +18,69 @@ 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, + ) + 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/hooks/useAssetTransfers.ts b/src/hooks/useAssetTransfers.ts index 11175435..e2a239bf 100644 --- a/src/hooks/useAssetTransfers.ts +++ b/src/hooks/useAssetTransfers.ts @@ -40,7 +40,7 @@ export type TransferItem = { } } -const alchemyFetcher = async ([address, chainId, direction]: [ +export const alchemyFetcher = async ([address, chainId, direction]: [ string, number, TransferDirection, From 9499b6e2b073f19ca9653055bf5a9d08737ff5fe Mon Sep 17 00:00:00 2001 From: Michael Gingras Date: Thu, 6 Apr 2023 14:10:03 -0600 Subject: [PATCH 5/8] mobile styles --- src/components/assets/AssetTransfersTab.tsx | 73 ++++++++++++++------- src/components/core/TabBar.tsx | 2 +- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/components/assets/AssetTransfersTab.tsx b/src/components/assets/AssetTransfersTab.tsx index 276a94c9..cc7be4d6 100644 --- a/src/components/assets/AssetTransfersTab.tsx +++ b/src/components/assets/AssetTransfersTab.tsx @@ -181,25 +181,50 @@ const TransactionItem = ({ }} -
{ - setSliderOpen(true) - }} - > -
-
-
- -
- {note ?? "Sent | Received"} -
-
-
{value}
-
{date}
-
- {category ?? "Uncategorized"} - {/* {Object.keys(TokenTransferVariant).map((key) => ( ))} */} -
-
+
+
+ ) + } + }} + ) } @@ -278,7 +307,7 @@ export const AssetTransfersTab = ({ return (
-
+
Description
Amount
Date
diff --git a/src/components/core/TabBar.tsx b/src/components/core/TabBar.tsx index 5c410c27..a27f8c57 100644 --- a/src/components/core/TabBar.tsx +++ b/src/components/core/TabBar.tsx @@ -64,7 +64,7 @@ export const TabBar = ({ ) : ( // style = "filter" -
+
Date: Thu, 6 Apr 2023 16:21:01 -0600 Subject: [PATCH 6/8] error handling --- src/components/assets/AssetTransfersTab.tsx | 9 +++++---- .../pages/assets/components/AssetPageContent.tsx | 8 +++++++- src/components/ui/Select.tsx | 4 ++-- src/hooks/tokenTransfer/useUpsertTokenTransfer.ts | 4 ++-- src/hooks/useIsSigner.ts | 1 + 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/assets/AssetTransfersTab.tsx b/src/components/assets/AssetTransfersTab.tsx index cc7be4d6..50421afa 100644 --- a/src/components/assets/AssetTransfersTab.tsx +++ b/src/components/assets/AssetTransfersTab.tsx @@ -14,6 +14,7 @@ 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, @@ -54,6 +55,7 @@ const TransactionItem = ({ note, category, }: TransactionProps) => { + const isSigner = useIsSigner({ address: terminalAddress, chainId }) const [sliderOpen, setSliderOpen] = useState(false) const blockExplorer = (networks as Record)[String(chainId)] .explorer @@ -68,7 +70,6 @@ const TransactionItem = ({ handleSubmit, formState: { errors, isSubmitting }, control, - watch, } = useForm({ mode: "all", defaultValues: { @@ -83,6 +84,7 @@ const TransactionItem = ({ note: data.description, category: data.category, }) + setSliderOpen(false) } const onError = (errors: any) => { @@ -131,7 +133,7 @@ const TransactionItem = ({ name="category" render={({ field: { onChange, ref } }) => ( - - {Object.keys(TokenTransferVariant).map((key) => ( - - ))} - */} +
+ {toChecksumAddress(from) === + toChecksumAddress(terminalAddress) ? ( + + ) : ( + + )} + {value}
+
{date}
+
{category ?? "Uncategorized"}
) } @@ -251,10 +266,17 @@ const TransactionItem = ({ } 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: @@ -277,17 +299,6 @@ export const AssetTransfersTab = ({ ) const { data } = useAssetTransfers(address, chainId, direction) - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - control, - watch, - } = useForm({ - mode: "all", // validate on all event handlers (onBlur, onChange, onSubmit) - defaultValues: {} as FieldValues, - }) - if (!data?.length) { const title = "Deposit tokens" const subtitle = @@ -304,14 +315,6 @@ export const AssetTransfersTab = ({ ) } - const onSubmit = (data: any) => { - console.log(data) - } - - const onError = (errors: any) => { - console.log(errors) - } - return (
@@ -320,7 +323,7 @@ export const AssetTransfersTab = ({
Date
Category
-
+
{data?.map((tx: TransferItem) => ( ))} - +
) } 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"