From df3e0438a9011d5b6b02e2981c619a0286081a74 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 13:07:17 +0100 Subject: [PATCH 01/41] chore: more mock data --- src/mocks/db.ts | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/mocks/db.ts b/src/mocks/db.ts index 761ce13..f14d182 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -83,7 +83,7 @@ const CHARLIE = db.identity_public_data.create({ zip: faker.location.zipCode() }) -const AMOUNT_OF_BILLS = 12 +const AMOUNT_OF_BILLS = 100 const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map(() => db.bill.create({ id: faker.string.uuid(), sum: faker.number.int({ min: 21, max: 21 * 1_000}), @@ -94,34 +94,32 @@ const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map(() => db.bill.create holder: CHARLIE })) -BILLS.slice(0, 3).forEach((bill) => { - db.quotes.create({ - id: faker.string.uuid(), - status: "pending", - bill, - }) -}) +const PENDING_BILLS = BILLS.slice(0, 3) +PENDING_BILLS.forEach((bill) => db.quotes.create({ + id: faker.string.uuid(), + status: "pending", + bill, +})) -BILLS.slice(3, 6).forEach((bill) => { - db.quotes.create({ - id: faker.string.uuid(), - status: "offered", - bill, - }) -}) +const OFFERED_BILLS = BILLS.slice(3, 6) +OFFERED_BILLS.forEach((bill) => db.quotes.create({ + id: faker.string.uuid(), + status: "offered", + bill, +})) -BILLS.slice(6, 9).forEach((bill) => { - db.quotes.create({ - id: faker.string.uuid(), - status: "accepted", - bill, - }) -}) +const REJECTED_BILLS = BILLS.slice(6, 9) +REJECTED_BILLS.forEach((bill) => db.quotes.create({ + id: faker.string.uuid(), + status: "rejected", + bill, +})) -BILLS.slice(9, 12).forEach((bill) => { +const ACCEPTED_BILLS = BILLS.slice(9, 32) +ACCEPTED_BILLS.forEach((bill) => { db.quotes.create({ id: faker.string.uuid(), - status: "rejected", + status: "accepted", bill, }) }) From c37ef94fe93405aa93838c75c29e7ef904daea84 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 14:46:38 +0100 Subject: [PATCH 02/41] chore: display bill drawer, drawee, payee and holder --- src/constants/endpoints.ts | 2 +- src/mocks/db.ts | 74 +++++++++++++++++------------- src/mocks/handlers/admin_quotes.ts | 1 + src/pages/quotes/QuotePage.tsx | 24 +++++++++- 4 files changed, 65 insertions(+), 36 deletions(-) diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts index 44dd9ff..dcd218f 100644 --- a/src/constants/endpoints.ts +++ b/src/constants/endpoints.ts @@ -3,7 +3,7 @@ const BALANCES = "/v1/balances" const ADMIN_QUOTE_PENDING = "/v1/admin/credit/quote/pending" const ADMIN_QUOTE_ACCEPTED = "/v1/admin/credit/quote/accepted" -const ADMIN_QUOTE_BY_ID = "/v1/admin/credit/quote/:id" // TODO: unused? +const ADMIN_QUOTE_BY_ID = "/v1/admin/credit/quote/:id" const CREDIT_QUOTE = "/v1/credit/mint/quote" const CREDIT_QUOTES_BY_ID = "/v1/credit/mint/quote/:id" diff --git a/src/mocks/db.ts b/src/mocks/db.ts index f14d182..ee0678f 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -1,5 +1,5 @@ import { factory, nullable, oneOf, primaryKey } from "@mswjs/data" -import { faker } from '@faker-js/faker' +import { faker } from "@faker-js/faker" // Seed `faker` to ensure reproducible random values of model properties. faker.seed(21_000_000) @@ -13,7 +13,7 @@ export const db = factory({ }, quotes: { id: primaryKey(String), - bill: oneOf('bill'), + bill: oneOf("bill"), status: nullable(String), submitted: nullable(String), // pending suggested_expiration: nullable(String), // pending @@ -23,10 +23,10 @@ export const db = factory({ }, bill: { id: primaryKey(String), - drawee: nullable(oneOf('identity_public_data')), - drawer: nullable(oneOf('identity_public_data')), - holder: nullable(oneOf('identity_public_data')), - payee: nullable(oneOf('identity_public_data')), + drawee: nullable(oneOf("identity_public_data")), + drawer: nullable(oneOf("identity_public_data")), + holder: nullable(oneOf("identity_public_data")), + payee: nullable(oneOf("identity_public_data")), sum: Number, maturity_date: String, }, @@ -58,7 +58,7 @@ const ALICE = db.identity_public_data.create({ address: faker.location.streetAddress(), city: faker.location.city(), country: faker.location.country(), - zip: faker.location.zipCode() + zip: faker.location.zipCode(), }) const BOB = db.identity_public_data.create({ @@ -69,7 +69,7 @@ const BOB = db.identity_public_data.create({ address: faker.location.streetAddress(), city: faker.location.city(), country: faker.location.country(), - zip: faker.location.zipCode() + zip: faker.location.zipCode(), }) const CHARLIE = db.identity_public_data.create({ @@ -80,40 +80,48 @@ const CHARLIE = db.identity_public_data.create({ address: faker.location.streetAddress(), city: faker.location.city(), country: faker.location.country(), - zip: faker.location.zipCode() + zip: faker.location.zipCode(), }) const AMOUNT_OF_BILLS = 100 -const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map(() => db.bill.create({ - id: faker.string.uuid(), - sum: faker.number.int({ min: 21, max: 21 * 1_000}), - maturity_date: faker.date.future({ years: 1 }).toUTCString(), - drawee: ALICE, - drawer: BOB, - payee: ALICE, - holder: CHARLIE -})) +const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map(() => + db.bill.create({ + id: faker.string.uuid(), + sum: faker.number.int({ min: 21, max: 21 * 1_000 }), + maturity_date: faker.date.future({ years: 1 }).toUTCString(), + drawee: ALICE, + drawer: BOB, + payee: ALICE, + holder: CHARLIE, + }), +) const PENDING_BILLS = BILLS.slice(0, 3) -PENDING_BILLS.forEach((bill) => db.quotes.create({ - id: faker.string.uuid(), - status: "pending", - bill, -})) +PENDING_BILLS.forEach((bill) => + db.quotes.create({ + id: faker.string.uuid(), + status: "pending", + bill, + }), +) const OFFERED_BILLS = BILLS.slice(3, 6) -OFFERED_BILLS.forEach((bill) => db.quotes.create({ - id: faker.string.uuid(), - status: "offered", - bill, -})) +OFFERED_BILLS.forEach((bill) => + db.quotes.create({ + id: faker.string.uuid(), + status: "offered", + bill, + }), +) const REJECTED_BILLS = BILLS.slice(6, 9) -REJECTED_BILLS.forEach((bill) => db.quotes.create({ - id: faker.string.uuid(), - status: "rejected", - bill, -})) +REJECTED_BILLS.forEach((bill) => + db.quotes.create({ + id: faker.string.uuid(), + status: "rejected", + bill, + }), +) const ACCEPTED_BILLS = BILLS.slice(9, 32) ACCEPTED_BILLS.forEach((bill) => { diff --git a/src/mocks/handlers/admin_quotes.ts b/src/mocks/handlers/admin_quotes.ts index 298f10f..11f0021 100644 --- a/src/mocks/handlers/admin_quotes.ts +++ b/src/mocks/handlers/admin_quotes.ts @@ -73,6 +73,7 @@ export const updateAdminQuote = http.post( if (body.action === "offer") { quote.status = "offered" quote.ttl = body.ttl ?? null + // TODO: not yet impelemnted: quote.discount = body.discount ?? null } const updated = db.quotes.update({ diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 420ac71..82f9212 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -123,8 +123,28 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) - bill: - {value.bill ?
{JSON.stringify(value.bill, null, 2)}
: "(empty)"}
+ sum: + {value.bill?.sum} sat +
+ + maturiy date: + {value.bill?.maturity_date} + + + drawee: + {value.bill ?
{JSON.stringify(value.bill?.drawee, null, 2)}
: "(empty)"}
+
+ + drawer: + {value.bill ?
{JSON.stringify(value.bill?.drawer, null, 2)}
: "(empty)"}
+
+ + payee: + {value.bill ?
{JSON.stringify(value.bill?.payee, null, 2)}
: "(empty)"}
+
+ + holder: + {value.bill ?
{JSON.stringify(value.bill?.holder, null, 2)}
: "(empty)"}
From 5f8ac5a6e6894795edacaa42c9e2444267622455 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 14:57:38 +0100 Subject: [PATCH 03/41] chore: IdentityPublicDataCard component --- src/pages/quotes/QuotePage.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 82f9212..41b72f0 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" -import { InfoReply } from "@/generated/client" +import { IdentityPublicData, InfoReply } from "@/generated/client" import { adminLookupQuoteOptions, adminLookupQuoteQueryKey, @@ -104,6 +104,17 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo ) } +function IdentityPublicDataCard({ value } : { value?: IdentityPublicData}) { + return (<> +
+
{value?.name}
+
{value?.email}
+
{value?.address}, {value?.zip}, {value?.city}, {value?.country}
+
{value?.node_id}
+
+ ) +} + function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { return ( <> @@ -127,24 +138,24 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) {value.bill?.sum} sat - maturiy date: + maturity date: {value.bill?.maturity_date} drawee: - {value.bill ?
{JSON.stringify(value.bill?.drawee, null, 2)}
: "(empty)"}
+
drawer: - {value.bill ?
{JSON.stringify(value.bill?.drawer, null, 2)}
: "(empty)"}
+
payee: - {value.bill ?
{JSON.stringify(value.bill?.payee, null, 2)}
: "(empty)"}
+
holder: - {value.bill ?
{JSON.stringify(value.bill?.holder, null, 2)}
: "(empty)"}
+
From 8cfed67a03ddf5a674908d41bab3317d63ec323a Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 16:17:16 +0100 Subject: [PATCH 04/41] chore: format maturity date --- src/pages/quotes/QuotePage.tsx | 52 +++++++++++++++++++++++++--------- src/utils/dates.ts | 13 +++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 src/utils/dates.ts diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 41b72f0..db03d9f 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -11,6 +11,7 @@ import { resolveQuoteMutation, } from "@/generated/client/@tanstack/react-query.gen" import useLocalStorage from "@/hooks/use-local-storage" +import { formatDate, humanReadableDurationDays } from "@/utils/dates" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" import { Suspense } from "react" @@ -104,15 +105,21 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo ) } -function IdentityPublicDataCard({ value } : { value?: IdentityPublicData}) { - return (<> -
-
{value?.name}
-
{value?.email}
-
{value?.address}, {value?.zip}, {value?.city}, {value?.country}
-
{value?.node_id}
-
- ) +function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) { + return ( + <> +
+
{value?.name}
+
{value?.email}
+
+ {value?.address}, {value?.zip}, {value?.city}, {value?.country} +
+
+
{value?.node_id}
+
+
+ + ) } function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { @@ -139,23 +146,40 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) maturity date: - {value.bill?.maturity_date} + + {!value.bill?.maturity_date ? ( + <>(empty) + ) : ( +
+ {formatDate("en", new Date(Date.parse(value.bill.maturity_date)))} + ({humanReadableDurationDays("en", new Date(Date.parse(value.bill.maturity_date)))}) +
+ )} +
drawee: - + + + drawer: - + + + payee: - + + + holder: - + + + diff --git a/src/utils/dates.ts b/src/utils/dates.ts new file mode 100644 index 0000000..8ddaac3 --- /dev/null +++ b/src/utils/dates.ts @@ -0,0 +1,13 @@ +export function humanReadableDurationDays(locale: string, from: Date, until = new Date(Date.now())) { + const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) + + const diffMillis = from.getTime() - until.getTime() + return relativeTimeFormatter.format(Math.round(diffMillis / (1000 * 60 * 60 * 24)), "day") +} + +export const formatDate = (locale: string, date: Date): string => { + const year = new Intl.DateTimeFormat(locale, { year: "2-digit" }).format(date) + const month = new Intl.DateTimeFormat(locale, { month: "short" }).format(date) + const day = new Intl.DateTimeFormat(locale, { day: "2-digit" }).format(date) + return `${day}-${month}-${year}` +} From c48a9ba1d65e94065e9761fa12dc6b0138f57275 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 16:37:32 +0100 Subject: [PATCH 05/41] chore: add avatars --- package-lock.json | 27 ++++++++++++++++++ package.json | 1 + src/components/ui/avatar.tsx | 51 ++++++++++++++++++++++++++++++++++ src/pages/quotes/QuotePage.tsx | 24 +++++++++++----- src/utils/dev.ts | 9 ++++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/utils/dev.ts diff --git a/package-lock.json b/package-lock.json index f1a8cb8..f25bf45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@hey-api/client-fetch": "^0.8.3", + "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-hover-card": "^1.1.6", @@ -1590,6 +1591,32 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.3.tgz", + "integrity": "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", diff --git a/package.json b/package.json index 0442b74..8715da7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@hey-api/client-fetch": "^0.8.3", + "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-hover-card": "^1.1.6", diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index db03d9f..ec26f1f 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -1,5 +1,6 @@ import { Breadcrumbs } from "@/components/Breadcrumbs" import { PageTitle } from "@/components/PageTitle" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -12,6 +13,7 @@ import { } from "@/generated/client/@tanstack/react-query.gen" import useLocalStorage from "@/hooks/use-local-storage" import { formatDate, humanReadableDurationDays } from "@/utils/dates" +import { randomAvatar } from "@/utils/dev" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" import { Suspense } from "react" @@ -108,14 +110,22 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) { return ( <> -
-
{value?.name}
-
{value?.email}
-
- {value?.address}, {value?.zip}, {value?.city}, {value?.country} +
+
+ + + {value?.name} +
-
-
{value?.node_id}
+
+
{value?.name}
+
{value?.email}
+
+ {value?.address}, {value?.zip}, {value?.city}, {value?.country} +
+
+
{value?.node_id}
+
diff --git a/src/utils/dev.ts b/src/utils/dev.ts new file mode 100644 index 0000000..3f50e32 --- /dev/null +++ b/src/utils/dev.ts @@ -0,0 +1,9 @@ +export const randomAvatar = (path: "men" | "women" | undefined, seed: string | undefined) => { + const _path = path ?? (Math.random() > 0.5 ? "men" : "women") + const _seed = + (seed ?? `${Math.floor(Math.random() * 100)}`) + .split("") + .map((it) => it.charCodeAt(0)) + .reduce((prev, curr) => prev + curr, 0) % 100 + return `https://randomuser.me/api/portraits/${_path}/${_seed}.jpg` +} From d0956cba8f20d1fa632fbbaa44b3be070b76fd84 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 16:47:16 +0100 Subject: [PATCH 06/41] chore: add participants card to quote details --- src/pages/quotes/QuotePage.tsx | 60 ++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index ec26f1f..13052e4 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -107,18 +107,55 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo ) } +function ParticipantsOverviewCard({ + drawee, + drawer, + holder, + payee, +}: { + drawee?: IdentityPublicData + drawer?: IdentityPublicData + holder?: IdentityPublicData + payee?: IdentityPublicData +}) { + return ( + <> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + ) +} + +function IdentityPublicDataAvatar({ value }: { value?: IdentityPublicData }) { + return ( + + + {value?.name} + + ) +} + function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) { return ( <>
-
- - - {value?.name} - +
+
-
{value?.name}
+
{value?.name}
{value?.email}
{value?.address}, {value?.zip}, {value?.city}, {value?.country} @@ -167,6 +204,17 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) )} + + participants: + + + + drawee: From 1b87572f28a688752d7483085d194b275135bf3c Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 17:08:22 +0100 Subject: [PATCH 07/41] chore: update holder of accepted quote mock --- src/mocks/db.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/mocks/db.ts b/src/mocks/db.ts index ee0678f..0eee675 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -43,13 +43,24 @@ export const db = factory({ }, }) -db.info.create({ +const MINT_INFO = db.info.create({ id: "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99", name: "Bob's Wildcat mint", pubkey: "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99", version: "Nutshell/0.15.0", }) +const MINT = db.identity_public_data.create({ + node_id: MINT_INFO.id, + name: MINT_INFO.name ?? undefined, + email: faker.internet.exampleEmail({ firstName: MINT_INFO.name ?? undefined }), + type: "Company", + address: faker.location.streetAddress(), + city: faker.location.city(), + country: faker.location.country(), + zip: faker.location.zipCode(), +}) + const ALICE = db.identity_public_data.create({ node_id: "02544d32dee119cd518cec548abeb2e8c3bcc8bd2dd5b9f1200794746d2cf8d8da", name: "Alice", @@ -124,6 +135,16 @@ REJECTED_BILLS.forEach((bill) => ) const ACCEPTED_BILLS = BILLS.slice(9, 32) +ACCEPTED_BILLS.forEach((bill) => { + return db.bill.update({ + where: { id: { equals: bill.id } }, + data: { + ...bill, + holder: MINT, + }, + }) +}) + ACCEPTED_BILLS.forEach((bill) => { db.quotes.create({ id: faker.string.uuid(), From 217b5ede99dd6d32372c23b7d6e670f182bbbf1f Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 17:20:41 +0100 Subject: [PATCH 08/41] chore: add tooltips to participants --- src/pages/quotes/QuotePage.tsx | 42 ++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 13052e4..3051fbf 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { IdentityPublicData, InfoReply } from "@/generated/client" import { adminLookupQuoteOptions, @@ -118,33 +119,44 @@ function ParticipantsOverviewCard({ holder?: IdentityPublicData payee?: IdentityPublicData }) { + ; return ( <>
- +
- +
- +
- +
) } -function IdentityPublicDataAvatar({ value }: { value?: IdentityPublicData }) { - return ( +function IdentityPublicDataAvatar({ value, tooltip }: { value?: IdentityPublicData; tooltip?: React.ReactNode }) { + const avatar = ( {value?.name} ) + return !tooltip ? ( + avatar + ) : ( + + + {avatar} + {tooltip} + + + ) } function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) { @@ -176,11 +188,11 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) - id: + ID: {value.id} - status: + Status: {value.status} @@ -188,11 +200,11 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) - sum: + Sum: {value.bill?.sum} sat - maturity date: + Maturity date: {!value.bill?.maturity_date ? ( <>(empty) @@ -205,7 +217,7 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) - participants: + Participants: - drawee: + Drawee: - drawer: + Drawer: - payee: + Payee: - holder: + Holder: From 98872d85c3a10172e7a34506032266be6e116b5f Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 19:24:21 +0100 Subject: [PATCH 09/41] chore: font-mono for uuids --- src/pages/quotes/QuotePage.tsx | 9 +++++-- src/pages/quotes/QuotesPage.tsx | 42 ++++++++++++++++++++++----------- src/utils/strings.ts | 4 ++++ 3 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 src/utils/strings.ts diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 3051fbf..92d3a53 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -15,6 +15,7 @@ import { import useLocalStorage from "@/hooks/use-local-storage" import { formatDate, humanReadableDurationDays } from "@/utils/dates" import { randomAvatar } from "@/utils/dev" +import { truncateString } from "@/utils/strings" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" import { Suspense } from "react" @@ -189,7 +190,9 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) ID: - {value.id} + + {value.id} + Status: @@ -321,7 +324,9 @@ export default function QuotePage() { > {id} - Quote {id} + + Quote {truncateString(id, 16)} + }> diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index 7d273ff..0057c9e 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -35,16 +35,23 @@ function QuoteListPending() {
+ {data.quotes.length === 0 && ( + <> +
💪 No pending quotes.
+ + )} {data.quotes.map((it, index) => { return (
- {isFetching ? ( - <>{it} - ) : ( - <> - {it} - - )} + + {isFetching ? ( + <>{it} + ) : ( + <> + {it} + + )} + @@ -109,7 +110,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo ) } -function ParticipantsOverviewCard({ +export function ParticipantsOverviewCard({ drawee, drawer, holder, diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index 0057c9e..fb53db2 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -2,13 +2,24 @@ import { Breadcrumbs } from "@/components/Breadcrumbs" import { H3 } from "@/components/Headings" import { PageTitle } from "@/components/PageTitle" import { Button } from "@/components/ui/button" +import { Card, CardFooter, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" -import { listAcceptedQuotesOptions, listPendingQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" +import { InfoReply } from "@/generated/client" +import { + adminLookupQuoteOptions, + listAcceptedQuotesOptions, + listPendingQuotesOptions, +} from "@/generated/client/@tanstack/react-query.gen" import useLocalStorage from "@/hooks/use-local-storage" import { useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" import { Suspense } from "react" import { Link, useNavigate } from "react-router" +import { ParticipantsOverviewCard } from "./QuotePage" +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" +import { formatDate, humanReadableDurationDays } from "@/utils/dates" +import { truncateString } from "@/utils/strings" +import { Badge } from "@/components/ui/badge" function Loader() { return ( @@ -18,9 +29,85 @@ function Loader() { ) } -function QuoteListPending() { +function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: boolean }) { const navigate = useNavigate() + const { data, isFetching } = useSuspenseQuery({ + enabled: false, + staleTime: 60 * 1_000, + ...adminLookupQuoteOptions({ + path: { + id, + }, + }), + }) + + return ( + <> + +
+ +
+ + {isFetching || isLoading ? ( + <>{truncateString(id, 16)} + ) : ( + <> + {truncateString(id, 16)} + + )} + + {isFetching && } +
+
+
{data.bill?.sum} sat
+ {humanReadableDurationDays("en", new Date(Date.parse(data.bill.maturity_date)))} +
+
+ + + Maturity date: + + {!data.bill?.maturity_date ? ( + <>(empty) + ) : ( +
+ {formatDate("en", new Date(Date.parse(data.bill.maturity_date)))} + ({humanReadableDurationDays("en", new Date(Date.parse(data.bill.maturity_date)))}) +
+ )} +
+
+ + Participants: + + + + +
+
+ + + + + + ) +} + +function QuoteListPending() { const { data, isFetching } = useSuspenseQuery({ ...listPendingQuotesOptions(), }) @@ -34,34 +121,12 @@ function QuoteListPending() { -
- {data.quotes.length === 0 && ( - <> -
💪 No pending quotes.
- - )} +
+ {data.quotes.length === 0 &&
💪 No pending quotes.
} {data.quotes.map((it, index) => { return ( -
- - {isFetching ? ( - <>{it} - ) : ( - <> - {it} - - )} - - - +
+
) })} @@ -86,12 +151,8 @@ function QuoteListAccepted() { -
- {data.quotes.length === 0 && ( - <> -
No accepted quotes.
- - )} +
+ {data.quotes.length === 0 &&
No accepted quotes.
} {data.quotes.map((it, index) => { return (
From 233f7b424a862c4c1d1b3569c16703ff759f7783 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 21:19:56 +0100 Subject: [PATCH 11/41] chore: format sum --- src/pages/quotes/QuotePage.tsx | 18 ++++++----- src/pages/quotes/QuotesPage.tsx | 57 +++++++++++++-------------------- src/utils/strings.ts | 4 +++ 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 0491920..5ecc43e 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -13,9 +13,10 @@ import { resolveQuoteMutation, } from "@/generated/client/@tanstack/react-query.gen" import useLocalStorage from "@/hooks/use-local-storage" +import { cn } from "@/lib/utils" import { formatDate, humanReadableDurationDays } from "@/utils/dates" import { randomAvatar } from "@/utils/dev" -import { truncateString } from "@/utils/strings" +import { formatNumber, truncateString } from "@/utils/strings" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" import { Suspense } from "react" @@ -115,26 +116,27 @@ export function ParticipantsOverviewCard({ drawer, holder, payee, + className, }: { drawee?: IdentityPublicData drawer?: IdentityPublicData holder?: IdentityPublicData payee?: IdentityPublicData + className?: string }) { - ; return ( <> -
-
+
+
-
+
-
+
-
+
@@ -205,7 +207,7 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) Sum: - {value.bill?.sum} sat + {formatNumber("en", value.bill?.sum)} sat Maturity date: diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index fb53db2..2c0a7b9 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -16,9 +16,8 @@ import { LoaderIcon } from "lucide-react" import { Suspense } from "react" import { Link, useNavigate } from "react-router" import { ParticipantsOverviewCard } from "./QuotePage" -import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table" -import { formatDate, humanReadableDurationDays } from "@/utils/dates" -import { truncateString } from "@/utils/strings" +import { humanReadableDurationDays } from "@/utils/dates" +import { formatNumber, truncateString } from "@/utils/strings" import { Badge } from "@/components/ui/badge" function Loader() { @@ -45,7 +44,7 @@ function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: bool return ( <> -
+
@@ -60,37 +59,19 @@ function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: bool {isFetching && }
-
{data.bill?.sum} sat
+
+ {formatNumber("en", data.bill?.sum)} sat +
{humanReadableDurationDays("en", new Date(Date.parse(data.bill.maturity_date)))} + +
- - - - Maturity date: - - {!data.bill?.maturity_date ? ( - <>(empty) - ) : ( -
- {formatDate("en", new Date(Date.parse(data.bill.maturity_date)))} - ({humanReadableDurationDays("en", new Date(Date.parse(data.bill.maturity_date)))}) -
- )} -
-
- - Participants: - - - - -
-
- {humanReadableDurationDays("en", new Date(Date.parse(data.bill.maturity_date)))} -
- - - ) From 43a42225962a70925e213a2ba02106a6bd7aede1 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 21:29:32 +0100 Subject: [PATCH 13/41] chore: collapsible sidebar --- src/components/AppSidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index f4c6fbd..2d7983f 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -43,7 +43,7 @@ const items = [ export function AppSidebar() { return ( - + Dashboard From d2171e43b7553cf53d1592a49428d19a118f5258 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 21:51:37 +0100 Subject: [PATCH 14/41] core: sidebar user menu --- package-lock.json | 101 +++++++++++ package.json | 2 + src/components/AppSidebar.tsx | 53 +++--- src/components/nav/NavMain.tsx | 82 +++++++++ src/components/nav/NavUser.tsx | 85 ++++++++++ src/components/ui/collapsible.tsx | 31 ++++ src/components/ui/dropdown-menu.tsx | 255 ++++++++++++++++++++++++++++ 7 files changed, 578 insertions(+), 31 deletions(-) create mode 100644 src/components/nav/NavMain.tsx create mode 100644 src/components/nav/NavUser.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/dropdown-menu.tsx diff --git a/package-lock.json b/package-lock.json index f25bf45..1d34192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "@hey-api/client-fetch": "^0.8.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", @@ -1647,6 +1649,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", @@ -1781,6 +1813,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1893,6 +1954,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", diff --git a/package.json b/package.json index 8715da7..dadf8e6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "@hey-api/client-fetch": "^0.8.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 2d7983f..a773de4 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,18 +1,9 @@ import { Bitcoin, Home, Inbox, Settings, InfoIcon } from "lucide-react" -import { NavLink } from "react-router" +import { Sidebar, SidebarContent, SidebarFooter, SidebarRail } from "@/components/ui/sidebar" +import { NavUser } from "./nav/NavUser" +import { randomAvatar } from "@/utils/dev" +import { NavMain } from "./nav/NavMain" -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" - -// Menu items. const items = [ { title: "Home", @@ -31,8 +22,14 @@ const items = [ }, { title: "Settings", - url: "/settings", + url: "#", icon: Settings, + items: [ + { + title: "General", + url: "/settings", + }, + ], }, { title: "Info", @@ -45,24 +42,18 @@ export function AppSidebar() { return ( - - Dashboard - - - {items.map((item) => ( - - - - - {item.title} - - - - ))} - - - + + + + + ) } diff --git a/src/components/nav/NavMain.tsx b/src/components/nav/NavMain.tsx new file mode 100644 index 0000000..bb2f31b --- /dev/null +++ b/src/components/nav/NavMain.tsx @@ -0,0 +1,82 @@ +"use client" + +import { ChevronRight, type LucideIcon } from "lucide-react" + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar" +import { NavLink } from "react-router" + +export function NavMain({ + items, +}: { + items: { + title: string + url: string + icon?: LucideIcon + isActive?: boolean + items?: { + title: string + url: string + }[] + }[] +}) { + return ( + + Platform + + {items.map((item) => ( + <> + {(item.items ?? []).length === 0 ? ( + <> + + + + {item.icon && } + {item.title} + + + + + ) : ( + <> + + + + + {item.icon && } + {item.title} + + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + + )} + + ))} + + + ) +} diff --git a/src/components/nav/NavUser.tsx b/src/components/nav/NavUser.tsx new file mode 100644 index 0000000..59857a8 --- /dev/null +++ b/src/components/nav/NavUser.tsx @@ -0,0 +1,85 @@ +import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from "lucide-react" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + {user.name} + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Account + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..77f86be --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..12d7c45 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} From 88724065830cb1f92f9d2c8915c8694882d25cad Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 21:54:36 +0100 Subject: [PATCH 15/41] chore: navbar secondary nav --- src/components/AppSidebar.tsx | 87 +++++++++++++++++------------ src/components/nav/NavSecondary.tsx | 40 +++++++++++++ 2 files changed, 92 insertions(+), 35 deletions(-) create mode 100644 src/components/nav/NavSecondary.tsx diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index a773de4..db14ac3 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,48 +1,65 @@ -import { Bitcoin, Home, Inbox, Settings, InfoIcon } from "lucide-react" +import { Bitcoin, Home, Inbox, Settings, InfoIcon, LifeBuoy, Send } from "lucide-react" import { Sidebar, SidebarContent, SidebarFooter, SidebarRail } from "@/components/ui/sidebar" import { NavUser } from "./nav/NavUser" import { randomAvatar } from "@/utils/dev" import { NavMain } from "./nav/NavMain" +import { NavSecondary } from "./nav/NavSecondary" -const items = [ - { - title: "Home", - url: "/", - icon: Home, - }, - { - title: "Balances", - url: "/balances", - icon: Bitcoin, - }, - { - title: "Quotes", - url: "/quotes", - icon: Inbox, - }, - { - title: "Settings", - url: "#", - icon: Settings, - items: [ - { - title: "General", - url: "/settings", - }, - ], - }, - { - title: "Info", - url: "/info", - icon: InfoIcon, - }, -] +const data = { + navMain: [ + { + title: "Home", + url: "/", + icon: Home, + }, + { + title: "Balances", + url: "/balances", + icon: Bitcoin, + }, + { + title: "Quotes", + url: "/quotes", + icon: Inbox, + }, + { + title: "Settings", + url: "/#", + icon: Settings, + items: [ + { + title: "General", + url: "/settings", + }, + ], + }, + { + title: "Info", + url: "/info", + icon: InfoIcon, + }, + ], + + navSecondary: [ + { + title: "Support", + url: "/#", + icon: LifeBuoy, + }, + { + title: "Feedback", + url: "/#", + icon: Send, + }, + ], +} export function AppSidebar() { return ( - + + ) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ) +} From aa0c596dc41bbf37a29098cf848c9f9a76a2f8ba Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Mon, 10 Mar 2025 22:20:14 +0100 Subject: [PATCH 16/41] fix: collapsible menu items --- src/components/AppSidebar.tsx | 2 +- src/components/nav/NavMain.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index db14ac3..20299aa 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -24,7 +24,7 @@ const data = { }, { title: "Settings", - url: "/#", + url: "/settings", icon: Settings, items: [ { diff --git a/src/components/nav/NavMain.tsx b/src/components/nav/NavMain.tsx index bb2f31b..0d3da29 100644 --- a/src/components/nav/NavMain.tsx +++ b/src/components/nav/NavMain.tsx @@ -12,6 +12,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + useSidebar, } from "@/components/ui/sidebar" import { NavLink } from "react-router" @@ -29,16 +30,18 @@ export function NavMain({ }[] }[] }) { + const { state } = useSidebar() + return ( - Platform + Dashboard {items.map((item) => ( <> - {(item.items ?? []).length === 0 ? ( + {(item.items ?? []).length === 0 || state === "collapsed" ? ( <> - + {item.icon && } {item.title} From c629d5bfa931b2ef855848c08d17464b227cf292 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Tue, 11 Mar 2025 12:36:11 +0100 Subject: [PATCH 17/41] chore: email address links --- src/components/AppSidebar.tsx | 2 +- src/components/nav/NavMain.tsx | 5 +---- src/pages/quotes/QuotePage.tsx | 6 +++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 20299aa..8f35e4c 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -66,7 +66,7 @@ export function AppSidebar() { user={{ name: "Account", email: "", - avatar: randomAvatar("men", ""), + avatar: randomAvatar("women", "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99"), }} /> diff --git a/src/components/nav/NavMain.tsx b/src/components/nav/NavMain.tsx index 0d3da29..8e0e609 100644 --- a/src/components/nav/NavMain.tsx +++ b/src/components/nav/NavMain.tsx @@ -1,7 +1,5 @@ -"use client" - import { ChevronRight, type LucideIcon } from "lucide-react" - +import { NavLink } from "react-router" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { SidebarGroup, @@ -14,7 +12,6 @@ import { SidebarMenuSubItem, useSidebar, } from "@/components/ui/sidebar" -import { NavLink } from "react-router" export function NavMain({ items, diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 5ecc43e..eff628d 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -172,7 +172,11 @@ function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) {
{value?.name}
-
{value?.email}
+
{value?.address}, {value?.zip}, {value?.city}, {value?.country}
From 6ea42a27649176ac048872a63816d5e1b7c2b97c Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Tue, 11 Mar 2025 12:41:09 +0100 Subject: [PATCH 18/41] chore: nav pointer --- src/components/nav/NavSecondary.tsx | 7 ++++--- src/components/nav/NavUser.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/nav/NavSecondary.tsx b/src/components/nav/NavSecondary.tsx index a931a7e..e260576 100644 --- a/src/components/nav/NavSecondary.tsx +++ b/src/components/nav/NavSecondary.tsx @@ -8,6 +8,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" +import { Link } from "react-router" export function NavSecondary({ items, @@ -25,11 +26,11 @@ export function NavSecondary({ {items.map((item) => ( - - + + {item.title} - + ))} diff --git a/src/components/nav/NavUser.tsx b/src/components/nav/NavUser.tsx index 59857a8..b5c9fd6 100644 --- a/src/components/nav/NavUser.tsx +++ b/src/components/nav/NavUser.tsx @@ -27,10 +27,11 @@ export function NavUser({ - + From cdc530e9488bbe229095a33f2e4cf8d585fe7aee Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 10:24:24 +0100 Subject: [PATCH 19/41] chore: prepare Earnings component --- package-lock.json | 30 ++++++++++++ package.json | 1 + src/components/ui/toggle-group.tsx | 71 +++++++++++++++++++++++++++++ src/pages/balances/BalancesPage.tsx | 56 ++++++++++++++++++++++- 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/toggle-group.tsx diff --git a/package-lock.json b/package-lock.json index 1d34192..b5a16b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.12", "@tanstack/react-query": "^5.67.2", @@ -2388,6 +2389,35 @@ } } }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", diff --git a/package.json b/package.json index dadf8e6..70c50e8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.12", "@tanstack/react-query": "^5.67.2", diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..5efb7ba --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,71 @@ +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +function ToggleGroup({ + className, + variant, + size, + children, + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant, + size, + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/pages/balances/BalancesPage.tsx b/src/pages/balances/BalancesPage.tsx index 807c72b..5cc4293 100644 --- a/src/pages/balances/BalancesPage.tsx +++ b/src/pages/balances/BalancesPage.tsx @@ -3,11 +3,12 @@ import { PageTitle } from "@/components/PageTitle" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts" import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent } from "@/components/ui/chart" -import { PropsWithChildren, Suspense } from "react" +import { PropsWithChildren, Suspense, useState } from "react" import { Skeleton } from "@/components/ui/skeleton" import { BalancesResponse, fetchBalances } from "@/lib/api" import { useSuspenseQuery } from "@tanstack/react-query" import useLocalStorage from "@/hooks/use-local-storage" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" function Loader() { return ( @@ -125,6 +126,57 @@ export function BalanceText({ value, children }: PropsWithChildren<{ value: Bala ) } +function Earnings() { + const [timeframe, setTimeframe] = useState("1d") + + return ( +
+
+
+
0.00 000 000 BTC
+
+
+
Earned during the selected timeframe
+
+
+ +
+ setTimeframe((curr) => val || curr)} + > + + Today + + + Last week + + + Last month + + + Last 3 months + + + Last 6 months + + + Last year + + +
+
+
+
No accepted quotes for the selected timeframe.
+
+
+
+ ) +} + function PageBody() { const { data } = useSuspenseQuery({ queryKey: ["balances"], @@ -168,6 +220,8 @@ function PageBody() {
+ +
From fe80eb6a0c4ae36594d150e24f9bbb8fc7e6a271 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 10:40:35 +0100 Subject: [PATCH 20/41] build(deps): update dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @eslint/js ^9.21.0 → ^9.22.0 @hey-api/openapi-ts ^0.64.10 → ^0.64.11 @tailwindcss/vite ^4.0.12 → ^4.0.13 @tanstack/react-query ^5.67.2 → ^5.67.3 @types/node ^22.13.9 → ^22.13.10 eslint ^9.21.0 → ^9.22.0 tailwindcss ^4.0.12 → ^4.0.13 typescript-eslint ^8.26.0 → ^8.26.1 --- package-lock.json | 391 ++++++++++++++++++++++++---------------------- package.json | 16 +- 2 files changed, 209 insertions(+), 198 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5a16b2..459d393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,8 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", - "@tailwindcss/vite": "^4.0.12", - "@tanstack/react-query": "^5.67.2", + "@tailwindcss/vite": "^4.0.13", + "@tanstack/react-query": "^5.67.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint-plugin-react": "^7.37.4", @@ -38,22 +38,22 @@ "react-router": "^7.3.0", "recharts": "^2.15.1", "tailwind-merge": "^3.0.2", - "tailwindcss": "^4.0.12", + "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.22.0", "@faker-js/faker": "^9.6.0", - "@hey-api/openapi-ts": "^0.64.10", + "@hey-api/openapi-ts": "^0.64.11", "@mswjs/data": "^0.16.2", "@testing-library/jest-dom": "^6.6.3", - "@types/node": "^22.13.9", + "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.0.8", - "eslint": "^9.21.0", + "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -62,7 +62,7 @@ "msw": "^2.7.3", "prettier": "^3.5.3", "typescript": "~5.8.2", - "typescript-eslint": "^8.26.0", + "typescript-eslint": "^8.26.1", "vite": "^6.2.1", "vitest": "^3.0.8" } @@ -990,6 +990,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", + "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", @@ -1038,9 +1047,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", - "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", + "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1149,9 +1158,9 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.64.10", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.10.tgz", - "integrity": "sha512-mTTD4DtOt68OmrZ6VXM4+sCma+JxhqDjiqdaUCpLIS8yWNWAmgBCRS5LE3i8AS8HUN1dsCetTGFAIUT2rElDVg==", + "version": "0.64.11", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.11.tgz", + "integrity": "sha512-ehvi2P3cJY7GC5768N+OvYK8Pak0N3oSjosDmjUjc+W7C8JTbabiCLwszxLefCtqFyv2G2mLl0vqlCj6Bpy+DQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2610,42 +2619,42 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.12.tgz", - "integrity": "sha512-a6J11K1Ztdln9OrGfoM75/hChYPcHYGNYimqciMrvKXRmmPaS8XZTHhdvb5a3glz4Kd4ZxE1MnuFE2c0fGGmtg==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.13.tgz", + "integrity": "sha512-P9TmtE9Vew0vv5FwyD4bsg/dHHsIsAuUXkenuGUc5gm8fYgaxpdoxIKngCyEMEQxyCKR8PQY5V5VrrKNOx7exg==", "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.12" + "tailwindcss": "4.0.13" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.12.tgz", - "integrity": "sha512-DWb+myvJB9xJwelwT9GHaMc1qJj6MDXRDR0CS+T8IdkejAtu8ctJAgV4r1drQJLPeS7mNwq2UHW2GWrudTf63A==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.13.tgz", + "integrity": "sha512-pTH3Ex5zAWC9LbS+WsYAFmkXQW3NRjmvxkKJY3NP1x0KHBWjz0Q2uGtdGMJzsa0EwoZ7wq9RTbMH1UNPceCpWw==", "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.12", - "@tailwindcss/oxide-darwin-arm64": "4.0.12", - "@tailwindcss/oxide-darwin-x64": "4.0.12", - "@tailwindcss/oxide-freebsd-x64": "4.0.12", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.12", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.12", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.12", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.12", - "@tailwindcss/oxide-linux-x64-musl": "4.0.12", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.12", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.12" + "@tailwindcss/oxide-android-arm64": "4.0.13", + "@tailwindcss/oxide-darwin-arm64": "4.0.13", + "@tailwindcss/oxide-darwin-x64": "4.0.13", + "@tailwindcss/oxide-freebsd-x64": "4.0.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.13", + "@tailwindcss/oxide-linux-x64-musl": "4.0.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.13" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.12.tgz", - "integrity": "sha512-dAXCaemu3mHLXcA5GwGlQynX8n7tTdvn5i1zAxRvZ5iC9fWLl5bGnjZnzrQqT7ttxCvRwdVf3IHUnMVdDBO/kQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.13.tgz", + "integrity": "sha512-+9zmwaPQ8A9ycDcdb+hRkMn6NzsmZ4YJBsW5Xqq5EdOu9xlIgmuMuJauVzDPB5BSbIWfhPdZ+le8NeRZpl1coA==", "cpu": [ "arm64" ], @@ -2659,9 +2668,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.12.tgz", - "integrity": "sha512-vPNI+TpJQ7sizselDXIJdYkx9Cu6JBdtmRWujw9pVIxW8uz3O2PjgGGzL/7A0sXI8XDjSyRChrUnEW9rQygmJQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.13.tgz", + "integrity": "sha512-Bj1QGlEJSjs/205CIRfb5/jeveOqzJ4pFMdRxu0gyiYWxBRyxsExXqaD+7162wnLP/EDKh6S1MC9E/1GwEhLtA==", "cpu": [ "arm64" ], @@ -2675,9 +2684,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.12.tgz", - "integrity": "sha512-RL/9jM41Fdq4Efr35C5wgLx98BirnrfwuD+zgMFK6Ir68HeOSqBhW9jsEeC7Y/JcGyPd3MEoJVIU4fAb7YLg7A==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.13.tgz", + "integrity": "sha512-lRTkxjTpMGXhLLM5GjZ0MtjPczMuhAo9j7PeSsaU6Imkm7W7RbrXfT8aP934kS7cBBV+HKN5U19Z0WWaORfb8Q==", "cpu": [ "x64" ], @@ -2691,9 +2700,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.12.tgz", - "integrity": "sha512-7WzWiax+LguJcMEimY0Q4sBLlFXu1tYxVka3+G2M9KmU/3m84J3jAIV4KZWnockbHsbb2XgrEjtlJKVwHQCoRA==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.13.tgz", + "integrity": "sha512-p/YLyKhs+xFibVeAPlpMGDVMKgjChgzs12VnDFaaqRSJoOz+uJgRSKiir2tn50e7Nm4YYw35q/DRBwpDBNo1MQ==", "cpu": [ "x64" ], @@ -2707,9 +2716,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.12.tgz", - "integrity": "sha512-X9LRC7jjE1QlfIaBbXjY0PGeQP87lz5mEfLSVs2J1yRc9PSg1tEPS9NBqY4BU9v5toZgJgzKeaNltORyTs22TQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.13.tgz", + "integrity": "sha512-Ua/5ydE/QOTX8jHuc7M9ICWnaLi6K2MV/r+Ws2OppsOjy8tdlPbqYainJJ6Kl7ofm524K+4Fk9CQITPzeIESPw==", "cpu": [ "arm" ], @@ -2723,9 +2732,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.12.tgz", - "integrity": "sha512-i24IFNq2402zfDdoWKypXz0ZNS2G4NKaA82tgBlE2OhHIE+4mg2JDb5wVfyP6R+MCm5grgXvurcIcKWvo44QiQ==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.13.tgz", + "integrity": "sha512-/W1+Q6tBAVgZWh/bhfOHo4n7Ryh6E7zYj4bJd9SRbkPyLtRioyK3bi6RLuDj57sa7Amk/DeomSV9iycS0xqIPA==", "cpu": [ "arm64" ], @@ -2739,9 +2748,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.12.tgz", - "integrity": "sha512-LmOdshJBfAGIBG0DdBWhI0n5LTMurnGGJCHcsm9F//ISfsHtCnnYIKgYQui5oOz1SUCkqsMGfkAzWyNKZqbGNw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.13.tgz", + "integrity": "sha512-GQj6TWevNxwsYw20FdT2r2d1f7uiRsF07iFvNYxPIvIyPEV74eZ0zgFEsAH1daK1OxPy+LXdZ4grV17P5tVzhQ==", "cpu": [ "arm64" ], @@ -2755,9 +2764,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.12.tgz", - "integrity": "sha512-OSK667qZRH30ep8RiHbZDQfqkXjnzKxdn0oRwWzgCO8CoTxV+MvIkd0BWdQbYtYuM1wrakARV/Hwp0eA/qzdbw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.13.tgz", + "integrity": "sha512-sQRH09faifF9w9WS6TKDWr1oLi4hoPx0EIWXZHQK/jcjarDpXGQ2DbF0KnALJCwWBxOIP/1nrmU01fZwwMzY3g==", "cpu": [ "x64" ], @@ -2771,9 +2780,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.12.tgz", - "integrity": "sha512-uylhWq6OWQ8krV8Jk+v0H/3AZKJW6xYMgNMyNnUbbYXWi7hIVdxRKNUB5UvrlC3RxtgsK5EAV2i1CWTRsNcAnA==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.13.tgz", + "integrity": "sha512-Or1N8DIF3tP+LsloJp+UXLTIMMHMUcWXFhJLCsM4T7MzFzxkeReewRWXfk5mk137cdqVeUEH/R50xAhY1mOkTQ==", "cpu": [ "x64" ], @@ -2787,9 +2796,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.12.tgz", - "integrity": "sha512-XDLnhMoXZEEOir1LK43/gHHwK84V1GlV8+pAncUAIN2wloeD+nNciI9WRIY/BeFTqES22DhTIGoilSO39xDb2g==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.13.tgz", + "integrity": "sha512-u2mQyqCFrr9vVTP6sfDRfGE6bhOX3/7rInehzxNhHX1HYRIx09H3sDdXzTxnZWKOjIg3qjFTCrYFUZckva5PIg==", "cpu": [ "arm64" ], @@ -2803,9 +2812,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.12.tgz", - "integrity": "sha512-I/BbjCLpKDQucvtn6rFuYLst1nfFwSMYyPzkx/095RE+tuzk5+fwXuzQh7T3fIBTcbn82qH/sFka7yPGA50tLw==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.13.tgz", + "integrity": "sha512-sOEc4iCanp1Yqyeu9suQcEzfaUcHnqjBUgDg0ZXpjUMUwdSi37S1lu1RGoV1BYInvvGu3y3HHTmvsSfDhx2L8w==", "cpu": [ "x64" ], @@ -2819,24 +2828,24 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.12.tgz", - "integrity": "sha512-JM3gp601UJiryIZ9R2bSqalzcOy15RCybQ1Q+BJqDEwVyo4LkWKeqQAcrpHapWXY31OJFTuOUVBFDWMhzHm2Bg==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.13.tgz", + "integrity": "sha512-0XTd/NoVUAktIDaA4MdXhve0QWYh7WlZg20EHCuBFR80F8FhbVkRX+AY5cjbUP/IO2itHzt0iHc0iSE5kBUMhQ==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.0.12", - "@tailwindcss/oxide": "4.0.12", - "lightningcss": "^1.29.1", - "tailwindcss": "4.0.12" + "@tailwindcss/node": "4.0.13", + "@tailwindcss/oxide": "4.0.13", + "lightningcss": "1.29.2", + "tailwindcss": "4.0.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "node_modules/@tanstack/query-core": { - "version": "5.67.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.67.2.tgz", - "integrity": "sha512-+iaFJ/pt8TaApCk6LuZ0WHS/ECVfTzrxDOEL9HH9Dayyb5OVuomLzDXeSaI2GlGT/8HN7bDGiRXDts3LV+u6ww==", + "version": "5.67.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.67.3.tgz", + "integrity": "sha512-pq76ObpjcaspAW4OmCbpXLF6BCZP2Zr/J5ztnyizXhSlNe7fIUp0QKZsd0JMkw9aDa+vxDX/OY7N+hjNY/dCGg==", "license": "MIT", "funding": { "type": "github", @@ -2844,12 +2853,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.67.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.2.tgz", - "integrity": "sha512-6Sa+BVNJWhAV4QHvIqM73norNeGRWGC3ftN0Ix87cmMvI215I1wyJ44KUTt/9a0V9YimfGcg25AITaYVel71Og==", + "version": "5.67.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.67.3.tgz", + "integrity": "sha512-u/n2HsQeH1vpZIOzB/w2lqKlXUDUKo6BxTdGXSMvNzIq5MHYFckRMVuFABp+QB7RN8LFXWV6X1/oSkuDq+MPIA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.67.2" + "@tanstack/query-core": "5.67.3" }, "funding": { "type": "github", @@ -3025,9 +3034,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3079,17 +3088,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", - "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", + "integrity": "sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/type-utils": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/type-utils": "8.26.1", + "@typescript-eslint/utils": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -3109,16 +3118,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", - "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.1.tgz", + "integrity": "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/typescript-estree": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4" }, "engines": { @@ -3134,14 +3143,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", - "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.1.tgz", + "integrity": "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0" + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3152,14 +3161,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", - "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.1.tgz", + "integrity": "sha512-Kcj/TagJLwoY/5w9JGEFV0dclQdyqw9+VMndxOJKtoFSjfZhLXhYjzsQEeyza03rwHx2vFEGvrJWJBXKleRvZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/utils": "8.26.0", + "@typescript-eslint/typescript-estree": "8.26.1", + "@typescript-eslint/utils": "8.26.1", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -3176,9 +3185,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", - "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.1.tgz", + "integrity": "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ==", "dev": true, "license": "MIT", "engines": { @@ -3190,14 +3199,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", - "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.1.tgz", + "integrity": "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3256,16 +3265,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.1.tgz", + "integrity": "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0" + "@typescript-eslint/scope-manager": "8.26.1", + "@typescript-eslint/types": "8.26.1", + "@typescript-eslint/typescript-estree": "8.26.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3280,13 +3289,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", - "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.1.tgz", + "integrity": "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -4557,15 +4566,12 @@ "license": "MIT" }, "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, "engines": { - "node": ">=0.10" + "node": ">=8" } }, "node_modules/detect-node-es": { @@ -4907,17 +4913,18 @@ } }, "node_modules/eslint": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", - "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", + "version": "9.22.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", + "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", + "@eslint/config-helpers": "^0.1.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.21.0", + "@eslint/js": "9.22.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4929,7 +4936,7 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", + "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", @@ -5032,7 +5039,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -5084,6 +5093,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -6456,12 +6467,12 @@ } }, "node_modules/lightningcss": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", - "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", "license": "MPL-2.0", "dependencies": { - "detect-libc": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { "node": ">= 12.0.0" @@ -6471,22 +6482,22 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.1", - "lightningcss-darwin-x64": "1.29.1", - "lightningcss-freebsd-x64": "1.29.1", - "lightningcss-linux-arm-gnueabihf": "1.29.1", - "lightningcss-linux-arm64-gnu": "1.29.1", - "lightningcss-linux-arm64-musl": "1.29.1", - "lightningcss-linux-x64-gnu": "1.29.1", - "lightningcss-linux-x64-musl": "1.29.1", - "lightningcss-win32-arm64-msvc": "1.29.1", - "lightningcss-win32-x64-msvc": "1.29.1" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", - "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", "cpu": [ "arm64" ], @@ -6504,9 +6515,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", - "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", "cpu": [ "x64" ], @@ -6524,9 +6535,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", - "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", "cpu": [ "x64" ], @@ -6544,9 +6555,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", - "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", "cpu": [ "arm" ], @@ -6564,9 +6575,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", - "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", "cpu": [ "arm64" ], @@ -6584,9 +6595,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", - "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", "cpu": [ "arm64" ], @@ -6604,9 +6615,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", - "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", "cpu": [ "x64" ], @@ -6624,9 +6635,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", - "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", "cpu": [ "x64" ], @@ -6644,9 +6655,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", - "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", "cpu": [ "arm64" ], @@ -6664,9 +6675,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", - "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", "cpu": [ "x64" ], @@ -8481,9 +8492,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz", - "integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.13.tgz", + "integrity": "sha512-gbvFrB0fOsTv/OugXWi2PtflJ4S6/ctu6Mmn3bCftmLY/6xRsQVEJPgIIpABwpZ52DpONkCA3bEj5b54MHxF2Q==", "license": "MIT" }, "node_modules/tailwindcss-animate": { @@ -8826,15 +8837,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.0.tgz", - "integrity": "sha512-PtVz9nAnuNJuAVeUFvwztjuUgSnJInODAUx47VDwWPXzd5vismPOtPtt83tzNXyOjVQbPRp786D6WFW/M2koIA==", + "version": "8.26.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.1.tgz", + "integrity": "sha512-t/oIs9mYyrwZGRpDv3g+3K6nZ5uhKEMt2oNmAPwaY4/ye0+EH4nXIPYNtkYFS6QHm+1DFg34DbglYBz5P9Xysg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "@typescript-eslint/utils": "8.26.0" + "@typescript-eslint/eslint-plugin": "8.26.1", + "@typescript-eslint/parser": "8.26.1", + "@typescript-eslint/utils": "8.26.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 70c50e8..e8383af 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", - "@tailwindcss/vite": "^4.0.12", - "@tanstack/react-query": "^5.67.2", + "@tailwindcss/vite": "^4.0.13", + "@tanstack/react-query": "^5.67.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint-plugin-react": "^7.37.4", @@ -46,22 +46,22 @@ "react-router": "^7.3.0", "recharts": "^2.15.1", "tailwind-merge": "^3.0.2", - "tailwindcss": "^4.0.12", + "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.22.0", "@faker-js/faker": "^9.6.0", - "@hey-api/openapi-ts": "^0.64.10", + "@hey-api/openapi-ts": "^0.64.11", "@mswjs/data": "^0.16.2", "@testing-library/jest-dom": "^6.6.3", - "@types/node": "^22.13.9", + "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^3.0.8", - "eslint": "^9.21.0", + "eslint": "^9.22.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -70,7 +70,7 @@ "msw": "^2.7.3", "prettier": "^3.5.3", "typescript": "~5.8.2", - "typescript-eslint": "^8.26.0", + "typescript-eslint": "^8.26.1", "vite": "^6.2.1", "vitest": "^3.0.8" }, From 6effeee02fdbca8385a791406d93cc11ac9054e4 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 11:42:11 +0100 Subject: [PATCH 21/41] chore: confirm drawer component --- src/components/ConfirmDrawer.tsx | 61 ++++++++++++++++++++++++ src/pages/quotes/QuotePage.tsx | 80 ++++++++++++++++++++++++++------ 2 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 src/components/ConfirmDrawer.tsx diff --git a/src/components/ConfirmDrawer.tsx b/src/components/ConfirmDrawer.tsx new file mode 100644 index 0000000..9651df7 --- /dev/null +++ b/src/components/ConfirmDrawer.tsx @@ -0,0 +1,61 @@ +import { Button } from "@/components/ui/button" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +type DrawerProps = Parameters[0] +type ConfirmDrawerProps = DrawerProps & { + title?: string + description?: string + cancelButtonText?: string + submitButtonText?: string + trigger: React.ReactNode + onSubmit: () => void + children?: React.ReactNode +} +export function ConfirmDrawer({ + title, + description, + cancelButtonText = "Cancel", + submitButtonText = "Confirm", + trigger, + onSubmit, + children, + ...drawerProps +}: ConfirmDrawerProps) { + return ( + + {trigger} + +
+ {title && ( + + {title} + {description && {description}} + + )} + {children} + +
+ + + + +
+
+
+
+
+ ) +} diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index eff628d..a735c3b 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -19,8 +19,10 @@ import { randomAvatar } from "@/utils/dev" import { formatNumber, truncateString } from "@/utils/strings" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" -import { Suspense } from "react" +import { Suspense, useState } from "react" import { Link, useParams } from "react-router" +import { ConfirmDrawer } from "@/components/ConfirmDrawer" +import { Drawer } from "@/components/ui/drawer" function Loader() { return ( @@ -30,7 +32,44 @@ function Loader() { ) } +type OfferConfirmDrawerProps = Parameters[0] & { + onSubmit: () => void + children: React.ReactNode +} + +function OfferConfirmDrawer({ children, onSubmit, ...drawerProps }: OfferConfirmDrawerProps) { + return ( + +
+
+ Are you sure you want to offer the quote? +
+
+
+ ) +} + +type DenyConfirmDrawerProps = Parameters[0] & { + onSubmit: () => void + children: React.ReactNode +} + +function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDrawerProps) { + return ( + +
+
+ Are you sure you want to deny the quote? +
+
+
+ ) +} + function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { + const [offerConfirmDrawerOpen, setOfferConfirmDrawerOpen] = useState(false) + const [denyConfirmDrawerOpen, setDenyConfirmDrawerOpen] = useState(false) + const queryClient = useQueryClient() const denyQuote = useMutation({ @@ -91,21 +130,34 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo return ( <>
- - + + { + onOfferQuote() + setOfferConfirmDrawerOpen(false) + }} > - Offer {offerQuote.isPending && } - + +
) From ec2c64a979e97bc5e6c9a15ffd55c53e3660ae03 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 11:59:53 +0100 Subject: [PATCH 22/41] chore: drawer submit button variant --- src/components/ConfirmDrawer.tsx | 8 ++++++-- src/pages/quotes/QuotePage.tsx | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/ConfirmDrawer.tsx b/src/components/ConfirmDrawer.tsx index 9651df7..512dd09 100644 --- a/src/components/ConfirmDrawer.tsx +++ b/src/components/ConfirmDrawer.tsx @@ -1,4 +1,4 @@ -import { Button } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { Drawer, DrawerClose, @@ -9,6 +9,7 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" +import { VariantProps } from "class-variance-authority" type DrawerProps = Parameters[0] type ConfirmDrawerProps = DrawerProps & { @@ -16,15 +17,18 @@ type ConfirmDrawerProps = DrawerProps & { description?: string cancelButtonText?: string submitButtonText?: string + submitButtonVariant?: VariantProps["variant"] trigger: React.ReactNode onSubmit: () => void children?: React.ReactNode } + export function ConfirmDrawer({ title, description, cancelButtonText = "Cancel", submitButtonText = "Confirm", + submitButtonVariant, trigger, onSubmit, children, @@ -49,7 +53,7 @@ export function ConfirmDrawer({ {cancelButtonText} -
diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index a735c3b..86e6a56 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -39,7 +39,7 @@ type OfferConfirmDrawerProps = Parameters[0] & { function OfferConfirmDrawer({ children, onSubmit, ...drawerProps }: OfferConfirmDrawerProps) { return ( - +
Are you sure you want to offer the quote? @@ -56,7 +56,13 @@ type DenyConfirmDrawerProps = Parameters[0] & { function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDrawerProps) { return ( - +
Are you sure you want to deny the quote? From bd8f8ae6cbdb610d20d5997fe8894c9c44eb6084 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 13:06:41 +0100 Subject: [PATCH 23/41] chore: add discount utils --- package-lock.json | 65 +++++++++++++++++++----- package.json | 4 ++ src/utils/dates.ts | 6 +++ src/utils/discount-util.test.ts | 90 +++++++++++++++++++++++++++++++++ src/utils/discount-util.ts | 19 +++++++ 5 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 src/utils/discount-util.test.ts create mode 100644 src/utils/discount-util.ts diff --git a/package-lock.json b/package-lock.json index 459d393..a50e05e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,10 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.13", "@tanstack/react-query": "^5.67.3", + "big.js": "^6.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.4", "lucide-react": "^0.479.0", "react": "^19.0.0", @@ -48,6 +50,8 @@ "@hey-api/openapi-ts": "^0.64.11", "@mswjs/data": "^0.16.2", "@testing-library/jest-dom": "^6.6.3", + "@types/big.js": "^6.2.2", + "@types/date-fns": "^2.5.3", "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -1476,6 +1480,23 @@ "msw": "^2.0.8" } }, + "node_modules/@mswjs/data/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.37.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", @@ -2940,6 +2961,13 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-e2cOW9YlVzFY2iScnGBBkplKsrn2CsObHQ2Hiw4V1sSyiGbgWL8IyqE3zFi1Pt5o1pdAtYkDAIsF3KKUPjdzaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -3009,6 +3037,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/date-fns": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz", + "integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "license": "MIT" @@ -3774,6 +3809,19 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "license": "MIT", @@ -4449,20 +4497,13 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/debug": { diff --git a/package.json b/package.json index e8383af..08fc859 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.13", "@tanstack/react-query": "^5.67.3", + "big.js": "^6.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.4", "lucide-react": "^0.479.0", "react": "^19.0.0", @@ -56,6 +58,8 @@ "@hey-api/openapi-ts": "^0.64.11", "@mswjs/data": "^0.16.2", "@testing-library/jest-dom": "^6.6.3", + "@types/big.js": "^6.2.2", + "@types/date-fns": "^2.5.3", "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", diff --git a/src/utils/dates.ts b/src/utils/dates.ts index 8ddaac3..0a2d38b 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -1,3 +1,9 @@ +import { differenceInCalendarDays } from "date-fns" + +export const daysBetween = (startDate: Date, endDate: Date): number => { + return differenceInCalendarDays(endDate, startDate) +} + export function humanReadableDurationDays(locale: string, from: Date, until = new Date(Date.now())) { const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) diff --git a/src/utils/discount-util.test.ts b/src/utils/discount-util.test.ts new file mode 100644 index 0000000..31556d1 --- /dev/null +++ b/src/utils/discount-util.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest" +import Big from "big.js" +import { daysBetween } from "@/utils/dates" +import { Act360 } from "./discount-util" + +describe("discount-util", () => { + describe("Act360", () => { + describe("netToGross", () => { + it("should calculate gross amount correctly (0)", () => { + const startDate = new Date(2024, 11, 6) + const endDate = new Date(2025, 2, 31) + const netAmount = new Big("10.12") + const discountRate = new Big("0.045") + + const days = daysBetween(startDate, endDate) + const grossAmount = Act360.netToGross(netAmount, discountRate, days) + expect(grossAmount).toStrictEqual(new Big("10.26759670259987317692")) + expect(grossAmount!.toNumber()).toBe(10.267596702599873) + }) + + it("should calculate gross amount correctly (1)", () => { + expect(Act360.netToGross(new Big("1"), new Big("1"), -1)).toStrictEqual(new Big("0.99722991689750692521")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 0)).toStrictEqual(new Big("1")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 1)).toStrictEqual(new Big("1.00278551532033426184")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 355)).toStrictEqual(new Big("71.99999999999999999424")) + expect(Act360.netToGross(new Big("1"), new Big("1"), 360)).toStrictEqual(undefined) + expect(Act360.netToGross(new Big("1"), new Big("1"), 365)).toStrictEqual(new Big("-71.99999999999999999424")) + }) + + it("should calculate gross amount correctly (2)", () => { + expect(Act360.netToGross(new Big(1), new Big("0.9863"), 365)).toStrictEqual(new Big("719999.999999999424")) + expect(Act360.netToGross(new Big(1), new Big("0.9864"), 365)).toStrictEqual(new Big("-10000")) + expect(Act360.netToGross(new Big(1), new Big("0.9865"), 365)).toStrictEqual( + new Big("-4965.51724137931031743163"), + ) + }) + + it("should calculate gross amount correctly (step-by-step)", () => { + const startDate = new Date(2024, 11, 6) + const endDate = new Date(2025, 2, 31) + const netAmount = new Big("10.12") + const discountRate = new Big("0.045") + + const days = daysBetween(startDate, endDate) + expect(days, "sanity check").toBe(115) + + const discountDays = discountRate.times(days).div(360) + expect(discountDays.toNumber(), "sanity check").toBe(0.014375) + + const factor = new Big(1).minus(discountDays) + + const grossAmount = netAmount.div(factor) + expect(grossAmount).toStrictEqual(new Big("10.26759670259987317692")) + expect(grossAmount.toNumber()).toBe(10.267596702599873) + + const calcGrossAmount = Act360.netToGross(netAmount, discountRate, days) + expect(calcGrossAmount).toStrictEqual(grossAmount) + }) + }) + + describe("grossToNet", () => { + it("should calculate net amount correctly (0)", () => { + const startDate = new Date(2024, 11, 6) + const endDate = new Date(2025, 2, 31) + const grossAmount = new Big("10.12") + const discountRate = new Big("0.045") + + const days = daysBetween(startDate, endDate) + const netAmount = Act360.grossToNet(grossAmount, discountRate, days) + expect(netAmount).toStrictEqual(new Big("9.974525")) + expect(netAmount.toNumber()).toBe(9.974525) + }) + + it("should calculate net amount correctly (1)", () => { + expect(Act360.grossToNet(new Big("1"), new Big("1"), -1)).toStrictEqual(new Big("1.00277777777777777778")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 0)).toStrictEqual(new Big("1")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 1)).toStrictEqual(new Big("0.99722222222222222222")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 355)).toStrictEqual(new Big("0.01388888888888888889")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 360)).toStrictEqual(new Big("0")) + expect(Act360.grossToNet(new Big("1"), new Big("1"), 365)).toStrictEqual(new Big("-0.01388888888888888889")) + }) + + it("should calculate net amount correctly (2)", () => { + expect(Act360.grossToNet(new Big(1), new Big("0.9863"), 365)).toStrictEqual(new Big("0.00000138888888888889")) + expect(Act360.grossToNet(new Big(1), new Big("0.9864"), 365)).toStrictEqual(new Big("-0.0001")) + expect(Act360.grossToNet(new Big(1), new Big("0.9865"), 365)).toStrictEqual(new Big("-0.00020138888888888889")) + }) + }) + }) +}) diff --git a/src/utils/discount-util.ts b/src/utils/discount-util.ts new file mode 100644 index 0000000..9150d04 --- /dev/null +++ b/src/utils/discount-util.ts @@ -0,0 +1,19 @@ +import Big from "big.js" + +const BIG_1 = new Big("1") +const BIG_360 = new Big("360") + +const factor = (discountRate: Big, days: number) => { + const discountDays = discountRate.times(days).div(BIG_360) + return BIG_1.minus(discountDays) +} + +export const Act360 = { + netToGross: (netAmount: Big, discountRate: Big, days: number): Big | undefined => { + const divisor = factor(discountRate, days) + return divisor.toNumber() !== 0 ? netAmount.div(divisor) : undefined + }, + grossToNet: (grossAmount: Big, discountRate: Big, days: number): Big => { + return grossAmount.times(factor(discountRate, days)) + }, +} From a5335e0619ba3118facdb65bd9f310e263a20d00 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 13:54:40 +0100 Subject: [PATCH 24/41] chore: human readable duration --- src/mocks/db.ts | 24 ++++++++++++++++++------ src/pages/quotes/QuotePage.tsx | 4 ++-- src/pages/quotes/QuotesPage.tsx | 4 ++-- src/utils/dates.ts | 30 +++++++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/mocks/db.ts b/src/mocks/db.ts index 0eee675..d6ff18e 100644 --- a/src/mocks/db.ts +++ b/src/mocks/db.ts @@ -95,11 +95,14 @@ const CHARLIE = db.identity_public_data.create({ }) const AMOUNT_OF_BILLS = 100 -const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map(() => +const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map((_, index) => db.bill.create({ id: faker.string.uuid(), sum: faker.number.int({ min: 21, max: 21 * 1_000 }), - maturity_date: faker.date.future({ years: 1 }).toUTCString(), + maturity_date: (faker.datatype.boolean() + ? faker.date.between({ from: Date.now(), to: Date.now() + (index + 1) * 1_000_000 }) + : faker.date.future({ years: 3, refDate: Date.now() }) + ).toUTCString(), drawee: ALICE, drawer: BOB, payee: ALICE, @@ -107,7 +110,16 @@ const BILLS = Array.from(Array(AMOUNT_OF_BILLS).keys()).map(() => }), ) -const PENDING_BILLS = BILLS.slice(0, 3) +db.bill.update({ + where: { id: { equals: BILLS[0].id } }, + data: { + ...BILLS[0], + // set a maturiy date in the past (just for testing) + maturity_date: faker.date.between({ from: Date.now() - 1_000_000, to: Date.now() }).toUTCString(), + }, +}) + +const PENDING_BILLS = BILLS.slice(0, 5) PENDING_BILLS.forEach((bill) => db.quotes.create({ id: faker.string.uuid(), @@ -116,7 +128,7 @@ PENDING_BILLS.forEach((bill) => }), ) -const OFFERED_BILLS = BILLS.slice(3, 6) +const OFFERED_BILLS = BILLS.slice(5, 10) OFFERED_BILLS.forEach((bill) => db.quotes.create({ id: faker.string.uuid(), @@ -125,7 +137,7 @@ OFFERED_BILLS.forEach((bill) => }), ) -const REJECTED_BILLS = BILLS.slice(6, 9) +const REJECTED_BILLS = BILLS.slice(10, 15) REJECTED_BILLS.forEach((bill) => db.quotes.create({ id: faker.string.uuid(), @@ -134,7 +146,7 @@ REJECTED_BILLS.forEach((bill) => }), ) -const ACCEPTED_BILLS = BILLS.slice(9, 32) +const ACCEPTED_BILLS = BILLS.slice(15, 30) ACCEPTED_BILLS.forEach((bill) => { return db.bill.update({ where: { id: { equals: bill.id } }, diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 86e6a56..f101960 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -14,7 +14,7 @@ import { } from "@/generated/client/@tanstack/react-query.gen" import useLocalStorage from "@/hooks/use-local-storage" import { cn } from "@/lib/utils" -import { formatDate, humanReadableDurationDays } from "@/utils/dates" +import { formatDate, humanReadableDuration } from "@/utils/dates" import { randomAvatar } from "@/utils/dev" import { formatNumber, truncateString } from "@/utils/strings" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" @@ -279,7 +279,7 @@ function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) ) : (
{formatDate("en", new Date(Date.parse(value.bill.maturity_date)))} - ({humanReadableDurationDays("en", new Date(Date.parse(value.bill.maturity_date)))}) + ({humanReadableDuration("en", new Date(Date.parse(value.bill.maturity_date)))})
)} diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index cd7b255..51ac6fe 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -16,7 +16,7 @@ import { LoaderIcon } from "lucide-react" import { Suspense } from "react" import { Link, useNavigate } from "react-router" import { ParticipantsOverviewCard } from "./QuotePage" -import { humanReadableDurationDays } from "@/utils/dates" +import { humanReadableDuration } from "@/utils/dates" import { formatNumber, truncateString } from "@/utils/strings" import { Badge } from "@/components/ui/badge" @@ -63,7 +63,7 @@ function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: bool
{formatNumber("en", data.bill?.sum)} sat
- {humanReadableDurationDays("en", new Date(Date.parse(data.bill.maturity_date)))} + {humanReadableDuration("en", new Date(Date.parse(data.bill.maturity_date)))}
diff --git a/src/utils/dates.ts b/src/utils/dates.ts index 0a2d38b..7c2ba2f 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -1,9 +1,37 @@ -import { differenceInCalendarDays } from "date-fns" +import { differenceInCalendarYears, differenceInMinutes } from "date-fns" +import { differenceInCalendarDays, differenceInCalendarMonths, differenceInHours, differenceInSeconds } from "date-fns" export const daysBetween = (startDate: Date, endDate: Date): number => { return differenceInCalendarDays(endDate, startDate) } +export function humanReadableDuration(locale: string, from: Date, until = new Date(Date.now())) { + const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) + + const diffYears = differenceInCalendarYears(from, until) + if (Math.abs(diffYears) >= 1) { + return relativeTimeFormatter.format(diffYears, "years") + } + const diffMonths = differenceInCalendarMonths(from, until) + if (Math.abs(diffMonths) >= 1) { + return relativeTimeFormatter.format(diffMonths, "months") + } + const diffDays = differenceInCalendarDays(from, until) + if (Math.abs(diffDays) >= 1) { + return relativeTimeFormatter.format(diffDays, "days") + } + const diffHours = differenceInHours(from, until) + if (Math.abs(diffHours) > 1) { + return relativeTimeFormatter.format(diffHours, "hours") + } + const diffMinutes = differenceInMinutes(from, until) + if (Math.abs(diffMinutes) > 1) { + return relativeTimeFormatter.format(diffMinutes, "minutes") + } + const diffSeconds = differenceInSeconds(from, until) + return relativeTimeFormatter.format(diffSeconds, "seconds") +} + export function humanReadableDurationDays(locale: string, from: Date, until = new Date(Date.now())) { const relativeTimeFormatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) From 2688abef6ca2ddd8b5936c390d93f792a677e151 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 16:08:57 +0100 Subject: [PATCH 25/41] chore: distinct pending and accepted quote pages --- src/components/AppSidebar.tsx | 10 + src/hooks/use-local-storage.ts | 2 +- src/main.tsx | 4 + src/pages/quotes/AcceptedQuotesPage.tsx | 127 +++++++++++++ src/pages/quotes/PendingQuotesPage.tsx | 176 +++++++++++++++++ src/pages/quotes/QuotesPage.tsx | 240 +++++------------------- 6 files changed, 369 insertions(+), 190 deletions(-) create mode 100644 src/pages/quotes/AcceptedQuotesPage.tsx create mode 100644 src/pages/quotes/PendingQuotesPage.tsx diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 8f35e4c..1e059f5 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -21,6 +21,16 @@ const data = { title: "Quotes", url: "/quotes", icon: Inbox, + items: [ + { + title: "Pending", + url: "/quotes/pending", + }, + { + title: "Accepted", + url: "/quotes/accepted", + }, + ], }, { title: "Settings", diff --git a/src/hooks/use-local-storage.ts b/src/hooks/use-local-storage.ts index a04c737..313a572 100644 --- a/src/hooks/use-local-storage.ts +++ b/src/hooks/use-local-storage.ts @@ -6,7 +6,7 @@ type DispatchAction = T | ((prevState: T) => T) export default function useLocalStorage(key: string, initialValue: T) { const [value, setValue] = useState(() => { const data = getItem(key) - return (data || initialValue) as T + return (data ?? initialValue) as T }) function handleDispatch(action: DispatchAction) { diff --git a/src/main.tsx b/src/main.tsx index 9ae3ffc..c3cd76a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,8 @@ import meta from "./constants/meta" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import InfoPage from "./pages/info/InfoPage" import QuotePage from "./pages/quotes/QuotePage" +import PendingQuotesPage from "./pages/quotes/PendingQuotesPage" +import AcceptedQuotesPage from "./pages/quotes/AcceptedQuotesPage" const queryClient = new QueryClient() @@ -31,6 +33,8 @@ void prepare().then(() => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/quotes/AcceptedQuotesPage.tsx b/src/pages/quotes/AcceptedQuotesPage.tsx new file mode 100644 index 0000000..25afa34 --- /dev/null +++ b/src/pages/quotes/AcceptedQuotesPage.tsx @@ -0,0 +1,127 @@ +import { Breadcrumbs } from "@/components/Breadcrumbs" +import { PageTitle } from "@/components/PageTitle" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { listAcceptedQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" +import useLocalStorage from "@/hooks/use-local-storage" +import { cn } from "@/lib/utils" +import { useSuspenseQuery } from "@tanstack/react-query" +import { LoaderIcon } from "lucide-react" +import { Suspense } from "react" +import { Link, useNavigate } from "react-router" + +function Loader() { + return ( +
+ + + + + + +
+ ) +} + +function QuoteListAccepted() { + const navigate = useNavigate() + + const { data, isFetching } = useSuspenseQuery({ + ...listAcceptedQuotesOptions(), + }) + + return ( + <> +
+ +
+ +
+ {data.quotes.length === 0 &&
No accepted quotes.
} + {data.quotes.map((it, index) => { + return ( +
+ + {isFetching ? ( + <>{it} + ) : ( + <> + {it} + + )} + + + +
+ ) + })} +
+ + ) +} + +function DevSection() { + const [devMode] = useLocalStorage("devMode", false) + + const { data: quotesAccepted } = useSuspenseQuery({ + ...listAcceptedQuotesOptions({}), + }) + + return ( + <> + {devMode && ( + <> +
+            {JSON.stringify(quotesAccepted, null, 2)}
+          
+ + )} + + ) +} + +function PageBody() { + return ( +
+
+ +
+
+ ) +} + +export default function AcceptedQuotesPage() { + return ( + <> + + Quotes + , + ]} + > + Accepted + + Accepted Quotes + }> + + + + + + + ) +} diff --git a/src/pages/quotes/PendingQuotesPage.tsx b/src/pages/quotes/PendingQuotesPage.tsx new file mode 100644 index 0000000..2dd6451 --- /dev/null +++ b/src/pages/quotes/PendingQuotesPage.tsx @@ -0,0 +1,176 @@ +import { Breadcrumbs } from "@/components/Breadcrumbs" +import { PageTitle } from "@/components/PageTitle" +import { Button } from "@/components/ui/button" +import { Card, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { InfoReply } from "@/generated/client" +import { adminLookupQuoteOptions, listPendingQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" +import useLocalStorage from "@/hooks/use-local-storage" +import { useSuspenseQuery } from "@tanstack/react-query" +import { LoaderIcon } from "lucide-react" +import { Suspense } from "react" +import { Link, useNavigate } from "react-router" +import { ParticipantsOverviewCard } from "./QuotePage" +import { humanReadableDuration } from "@/utils/dates" +import { formatNumber, truncateString } from "@/utils/strings" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +function Loader() { + return ( +
+ + + + + +
+ ) +} + +function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: boolean }) { + const navigate = useNavigate() + + const { data, isFetching } = useSuspenseQuery({ + enabled: false, + staleTime: 60 * 1_000, + ...adminLookupQuoteOptions({ + path: { + id, + }, + }), + }) + + return ( + <> + +
+ +
+ + {isFetching || isLoading ? ( + <>{truncateString(id, 16)} + ) : ( + <> + {truncateString(id, 16)} + + )} + + {isFetching && } +
+
+
+
+ {formatNumber("en", data.bill?.sum)} sat +
+ {humanReadableDuration("en", new Date(Date.parse(data.bill.maturity_date)))} +
+
+
+
+ +
+ +
+
+ + ) +} + +function QuoteListPending() { + const { data, isFetching } = useSuspenseQuery({ + ...listPendingQuotesOptions(), + }) + + return ( + <> +
+ +
+ +
+ {data.quotes.length === 0 &&
💪 No pending quotes.
} + {data.quotes.map((it, index) => { + return ( +
+ +
+ ) + })} +
+ + ) +} + +function DevSection() { + const [devMode] = useLocalStorage("devMode", false) + + const { data: quotesPending } = useSuspenseQuery({ + ...listPendingQuotesOptions({}), + }) + + return ( + <> + {devMode && ( + <> +
+            {JSON.stringify(quotesPending, null, 2)}
+          
+ + )} + + ) +} + +function PageBody() { + return ( +
+
+ +
+
+ ) +} + +export default function PendingQuotesPage() { + return ( + <> + + Quotes + , + ]} + > + Pending + + + Pending Quotes + }> + + + + + + + ) +} diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index 51ac6fe..1c80ee2 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -1,213 +1,76 @@ +import { Suspense } from "react" import { Breadcrumbs } from "@/components/Breadcrumbs" -import { H3 } from "@/components/Headings" import { PageTitle } from "@/components/PageTitle" -import { Button } from "@/components/ui/button" -import { Card, CardTitle } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" -import { InfoReply } from "@/generated/client" -import { - adminLookupQuoteOptions, - listAcceptedQuotesOptions, - listPendingQuotesOptions, -} from "@/generated/client/@tanstack/react-query.gen" -import useLocalStorage from "@/hooks/use-local-storage" +import { listAcceptedQuotesOptions, listPendingQuotesOptions } from "@/generated/client/@tanstack/react-query.gen" import { useSuspenseQuery } from "@tanstack/react-query" -import { LoaderIcon } from "lucide-react" -import { Suspense } from "react" -import { Link, useNavigate } from "react-router" -import { ParticipantsOverviewCard } from "./QuotePage" -import { humanReadableDuration } from "@/utils/dates" -import { formatNumber, truncateString } from "@/utils/strings" -import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" +import { Link } from "react-router" +import { ChevronRight, ChevronRightCircle } from "lucide-react" function Loader() { return (
- + +
) } -function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: boolean }) { - const navigate = useNavigate() +function PageBody() { + const { data: quotesPending } = useSuspenseQuery({ + ...listPendingQuotesOptions({}), + }) - const { data, isFetching } = useSuspenseQuery({ - enabled: false, - staleTime: 60 * 1_000, - ...adminLookupQuoteOptions({ - path: { - id, - }, - }), + const { data: quotesAccepted } = useSuspenseQuery({ + ...listAcceptedQuotesOptions({}), }) return ( - <> - -
- -
- - {isFetching || isLoading ? ( - <>{truncateString(id, 16)} +
+ + 0, + })} + > +
+
+ + Pending Quotes + + + {quotesPending.quotes.length === 0 ? ( + <>💪 No pending quotes. ) : ( - <> - {truncateString(id, 16)} - + <>{quotesPending.quotes.length} pending )} - - {isFetching && } +
- -
-
- {formatNumber("en", data.bill?.sum)} sat +
+
- {humanReadableDuration("en", new Date(Date.parse(data.bill.maturity_date)))} -
-
-
-
-
- -
- - - ) -} - -function QuoteListPending() { - const { data, isFetching } = useSuspenseQuery({ - ...listPendingQuotesOptions(), - }) - - return ( - <> -

- - Pending - {isFetching && } - -

- -
- {data.quotes.length === 0 &&
💪 No pending quotes.
} - {data.quotes.map((it, index) => { - return ( -
- + + + + +
+
+ + Accepted quotes + + {quotesAccepted.quotes.length} accepted
- ) - })} -
- - ) -} - -function QuoteListAccepted() { - const navigate = useNavigate() - - const { data, isFetching } = useSuspenseQuery({ - ...listAcceptedQuotesOptions(), - }) - - return ( - <> -

- - Accepted - {isFetching && } - -

- -
- {data.quotes.length === 0 &&
No accepted quotes.
} - {data.quotes.map((it, index) => { - return ( -
- - {isFetching ? ( - <>{it} - ) : ( - <> - {it} - - )} - - - +
+
- ) - })} -
- - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesPending } = useSuspenseQuery({ - ...listPendingQuotesOptions({}), - }) - - const { data: quotesAccepted } = useSuspenseQuery({ - ...listAcceptedQuotesOptions({}), - }) - - return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesPending, null, 2)}
-          
-
-            {JSON.stringify(quotesAccepted, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { - return ( - <> - }> -
-
-
-
- -
-
-
- + + +
) } @@ -216,9 +79,8 @@ export default function QuotesPage() { <> Quotes Quotes - - - + }> + ) From aeb915e1359113888a7b4647885881d87a8ccd83 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 16:12:34 +0100 Subject: [PATCH 26/41] chore: main nav key --- src/components/nav/NavMain.tsx | 78 ++++++----- src/pages/quotes/QuotePage.tsx | 222 ++++++++++++++++---------------- src/pages/quotes/QuotesPage.tsx | 2 +- 3 files changed, 148 insertions(+), 154 deletions(-) diff --git a/src/components/nav/NavMain.tsx b/src/components/nav/NavMain.tsx index 8e0e609..172af2d 100644 --- a/src/components/nav/NavMain.tsx +++ b/src/components/nav/NavMain.tsx @@ -33,49 +33,43 @@ export function NavMain({ Dashboard - {items.map((item) => ( - <> - {(item.items ?? []).length === 0 || state === "collapsed" ? ( - <> - - - - {item.icon && } - {item.title} - + {items.map((item) => + (item.items ?? []).length === 0 || state === "collapsed" ? ( + + + + {item.icon && } + {item.title} + + + + ) : ( + + + + + {item.icon && } + {item.title} + - - - ) : ( - <> - - - - - {item.icon && } - {item.title} - - - - - - {item.items?.map((subItem) => ( - - - - {subItem.title} - - - - ))} - - - - - - )} - - ))} + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + ), + )} ) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index f101960..6ba451c 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -40,7 +40,7 @@ type OfferConfirmDrawerProps = Parameters[0] & { function OfferConfirmDrawer({ children, onSubmit, ...drawerProps }: OfferConfirmDrawerProps) { return ( -
+
Are you sure you want to offer the quote?
@@ -63,7 +63,7 @@ function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDr submitButtonVariant="destructive" onSubmit={onSubmit} > -
+
Are you sure you want to deny the quote?
@@ -183,22 +183,20 @@ export function ParticipantsOverviewCard({ className?: string }) { return ( - <> -
-
- -
-
- -
-
- -
-
- -
+
+
+
- +
+ +
+
+ +
+
+ +
+
) } @@ -223,108 +221,104 @@ function IdentityPublicDataAvatar({ value, tooltip }: { value?: IdentityPublicDa function IdentityPublicDataCard({ value }: { value?: IdentityPublicData }) { return ( - <> -
-
- +
+
+ +
+
+
{value?.name}
+ -
-
{value?.name}
- -
- {value?.address}, {value?.zip}, {value?.city}, {value?.country} -
-
-
{value?.node_id}
-
+
+ {value?.address}, {value?.zip}, {value?.city}, {value?.country} +
+
+
{value?.node_id}
- +
) } function Quote({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { return ( - <> -
- - - - ID: - - {value.id} - - - - Status: - - - {value.status} - - - - - Sum: - {formatNumber("en", value.bill?.sum)} sat - - - Maturity date: - - {!value.bill?.maturity_date ? ( - <>(empty) - ) : ( -
- {formatDate("en", new Date(Date.parse(value.bill.maturity_date)))} - ({humanReadableDuration("en", new Date(Date.parse(value.bill.maturity_date)))}) -
- )} -
-
- - Participants: - - - - - - Drawee: - - - - - - Drawer: - - - - - - Payee: - - - - - - Holder: - - - - -
-
+
+ + + + ID: + + {value.id} + + + + Status: + + + {value.status} + + + + + Sum: + {formatNumber("en", value.bill?.sum)} sat + + + Maturity date: + + {!value.bill?.maturity_date ? ( + <>(empty) + ) : ( +
+ {formatDate("en", new Date(Date.parse(value.bill.maturity_date)))} + ({humanReadableDuration("en", new Date(Date.parse(value.bill.maturity_date)))}) +
+ )} +
+
+ + Participants: + + + + + + Drawee: + + + + + + Drawer: + + + + + + Payee: + + + + + + Holder: + + + + +
+
- -
- + +
) } @@ -364,7 +358,13 @@ function PageBody({ id }: { id: InfoReply["id"] }) { return ( <>
- {isFetching && } + {" "} +
diff --git a/src/pages/quotes/QuotesPage.tsx b/src/pages/quotes/QuotesPage.tsx index 1c80ee2..e16e417 100644 --- a/src/pages/quotes/QuotesPage.tsx +++ b/src/pages/quotes/QuotesPage.tsx @@ -7,7 +7,7 @@ import { useSuspenseQuery } from "@tanstack/react-query" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { cn } from "@/lib/utils" import { Link } from "react-router" -import { ChevronRight, ChevronRightCircle } from "lucide-react" +import { ChevronRight } from "lucide-react" function Loader() { return ( From 0c3968bf84684bedd366e7d663a264315577a463 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 16:44:43 +0100 Subject: [PATCH 27/41] chore: add discount form --- package-lock.json | 17 ++ package.json | 1 + src/components/GrossToNetDiscountForm.tsx | 217 ++++++++++++++++++++++ src/utils/numbers.test.ts | 34 ++++ src/utils/numbers.ts | 11 ++ 5 files changed, 280 insertions(+) create mode 100644 src/components/GrossToNetDiscountForm.tsx create mode 100644 src/utils/numbers.test.ts create mode 100644 src/utils/numbers.ts diff --git a/package-lock.json b/package-lock.json index a50e05e..ce49a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "lucide-react": "^0.479.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-router": "^7.3.0", "recharts": "^2.15.1", "tailwind-merge": "^3.0.2", @@ -7606,6 +7607,22 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 08fc859..1fe84e0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "lucide-react": "^0.479.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-router": "^7.3.0", "recharts": "^2.15.1", "tailwind-merge": "^3.0.2", diff --git a/src/components/GrossToNetDiscountForm.tsx b/src/components/GrossToNetDiscountForm.tsx new file mode 100644 index 0000000..fff2a21 --- /dev/null +++ b/src/components/GrossToNetDiscountForm.tsx @@ -0,0 +1,217 @@ +import { LabelHTMLAttributes, PropsWithChildren, useEffect, useMemo, useState } from "react" +import { useForm } from "react-hook-form" +import Big from "big.js" +import { cn } from "@/lib/utils" +import { parseFloatSafe, parseIntSafe } from "@/utils/numbers" +import { daysBetween } from "@/utils/dates" +import { Act360 } from "@/utils/discount-util" +import { Button } from "./ui/button" + +type InputContainerProps = PropsWithChildren<{ + htmlFor: LabelHTMLAttributes["htmlFor"] + label: React.ReactNode +}> + +const InputContainer = ({ children, htmlFor, label }: InputContainerProps) => { + return ( +
+ + {children} +
+ ) +} + +interface CurrencyAmount { + value: Big + currency: string +} + +interface CommonDiscountFormProps { + startDate?: Date + endDate: Date + onSubmit: (values: FormResult) => void +} + +type GrossToNetProps = CommonDiscountFormProps & { + gross: CurrencyAmount +} + +interface FormResult { + days: number + discountRate: Big + net: CurrencyAmount + gross: CurrencyAmount +} + +interface FormValues { + daysInput?: string + discountRateInput?: string +} + +const INPUT_DAYS_MIN_VALUE = 1 +const INPUT_DAYS_MAX_VALUE = 360 + +type GrossToNetFormValues = FormValues + +const GrossToNetDiscountForm = ({ startDate, endDate, gross, onSubmit }: GrossToNetProps) => { + const { + watch, + register, + setValue, + handleSubmit, + formState: { isValid, errors }, + } = useForm({ + mode: "all", + }) + + const { daysInput, discountRateInput } = watch() + + const days = useMemo(() => { + return parseIntSafe(daysInput) + }, [daysInput]) + + const discountRate = useMemo(() => { + const parsed = parseFloatSafe(discountRateInput) + return parsed === undefined ? undefined : new Big(parsed).div(new Big("100")) + }, [discountRateInput]) + + const [net, setNet] = useState() + + const discount = useMemo(() => { + return net === undefined + ? undefined + : { + value: net.value.sub(gross.value), + currency: net.currency, + } + }, [gross, net]) + + useEffect(() => { + if (startDate === undefined) return + setValue("daysInput", String(Math.min(daysBetween(startDate, endDate), INPUT_DAYS_MAX_VALUE)), { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + }, [startDate, endDate, setValue]) + + useEffect(() => { + if (!isValid || discountRate === undefined || days === undefined) { + setNet(undefined) + return + } + + setNet({ + value: Act360.grossToNet(gross.value, discountRate, days), + currency: gross.currency, + }) + }, [isValid, gross, days, discountRate]) + + return ( +
{ + handleSubmit(() => { + if (net === undefined || discountRate === undefined || days === undefined) return + + onSubmit({ + days, + discountRate, + net, + gross, + }) + })(e).catch(() => { + // TODO + }) + }} + > +
+ Days}> + + + {errors.daysInput && ( +
+ <> + Please enter a valid value between {INPUT_DAYS_MIN_VALUE} and {INPUT_DAYS_MAX_VALUE}. + +
+ )} +
+ +
+ Discount rate}> +
+ + % +
+
+ {errors.discountRateInput && ( +
+ <> + Please enter a valid value between {0}% and {99.9999}%. + +
+ )} +
+ +
+ <>Gross amount + +
+ {gross.value.toNumber()} + {gross.currency} +
+
+ +
+ <>Discount + +
+ {discount === undefined ? <>? : <>{discount.value.toNumber()}} + {discount?.currency} +
+
+ +
+ <>Net amount + +
+ {net === undefined ? <>? : <>{net.value.toNumber()}} + {net?.currency} +
+
+ + +
+ ) +} + +export { GrossToNetDiscountForm } diff --git a/src/utils/numbers.test.ts b/src/utils/numbers.test.ts new file mode 100644 index 0000000..1d03258 --- /dev/null +++ b/src/utils/numbers.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest" +import { parseFloatSafe, parseIntSafe } from "./numbers" + +describe("util", () => { + describe("parseFloatSafe", () => { + it("should safely parse floats", () => { + expect(parseFloatSafe("")).toBe(undefined) + expect(parseFloatSafe("NaN")).toBe(undefined) + expect(parseFloatSafe("Infinity")).toBe(undefined) + expect(parseFloatSafe(String(1 / 0))).toBe(undefined) + expect(parseFloatSafe("foobar")).toBe(undefined) + expect(parseFloatSafe("0")).toBe(0) + expect(parseFloatSafe("1")).toBe(1) + expect(parseFloatSafe("-1")).toBe(-1) + expect(parseFloatSafe("1.23456789")).toBe(1.23456789) + expect(parseFloatSafe("-1.23456789")).toBe(-1.23456789) + }) + }) + + describe("parseIntSafe", () => { + it("should safely parse ints", () => { + expect(parseIntSafe("")).toBe(undefined) + expect(parseIntSafe("NaN")).toBe(undefined) + expect(parseIntSafe("Infinity")).toBe(undefined) + expect(parseIntSafe(String(1 / 0))).toBe(undefined) + expect(parseIntSafe("foobar")).toBe(undefined) + expect(parseIntSafe("0")).toBe(0) + expect(parseIntSafe("1")).toBe(1) + expect(parseIntSafe("-1")).toBe(-1) + expect(parseIntSafe("1.23456789")).toBe(1) + expect(parseIntSafe("-1.23456789")).toBe(-1) + }) + }) +}) diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000..ed1ae06 --- /dev/null +++ b/src/utils/numbers.ts @@ -0,0 +1,11 @@ +export const parseFloatSafe = (str: string | undefined) => { + if (str === undefined) return undefined + const parsed = parseFloat(str) + return isNaN(parsed) || !isFinite(parsed) ? undefined : parsed +} + +export const parseIntSafe = (str: string | undefined) => { + if (str === undefined) return undefined + const parsed = parseInt(str, 10) + return isNaN(parsed) || !isFinite(parsed) ? undefined : parsed +} From 687b6e9609029f9a9e447647758188d5104de831 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Wed, 12 Mar 2025 17:46:09 +0100 Subject: [PATCH 28/41] chore: submit discount rate --- .../{ConfirmDrawer.tsx => Drawers.tsx} | 69 ++++++----- src/pages/quotes/QuotePage.tsx | 111 +++++++++++++++--- 2 files changed, 131 insertions(+), 49 deletions(-) rename src/components/{ConfirmDrawer.tsx => Drawers.tsx} (59%) diff --git a/src/components/ConfirmDrawer.tsx b/src/components/Drawers.tsx similarity index 59% rename from src/components/ConfirmDrawer.tsx rename to src/components/Drawers.tsx index 512dd09..3ab8bd5 100644 --- a/src/components/ConfirmDrawer.tsx +++ b/src/components/Drawers.tsx @@ -12,31 +12,16 @@ import { import { VariantProps } from "class-variance-authority" type DrawerProps = Parameters[0] -type ConfirmDrawerProps = DrawerProps & { +type BaseDrawerProps = DrawerProps & { title?: string description?: string - cancelButtonText?: string - submitButtonText?: string - submitButtonVariant?: VariantProps["variant"] - trigger: React.ReactNode - onSubmit: () => void + trigger?: React.ReactNode children?: React.ReactNode } - -export function ConfirmDrawer({ - title, - description, - cancelButtonText = "Cancel", - submitButtonText = "Confirm", - submitButtonVariant, - trigger, - onSubmit, - children, - ...drawerProps -}: ConfirmDrawerProps) { +export function BaseDrawer({ title, description, trigger, children, ...drawerProps }: BaseDrawerProps) { return ( - {trigger} + {trigger && {trigger}}
{title && ( @@ -46,20 +31,42 @@ export function ConfirmDrawer({ )} {children} - -
- - - - -
-
) } + +type ConfirmDrawerProps = BaseDrawerProps & { + cancelButtonText?: string + submitButtonText?: string + submitButtonVariant?: VariantProps["variant"] + onSubmit: () => void +} + +export function ConfirmDrawer({ + cancelButtonText = "Cancel", + submitButtonText = "Confirm", + submitButtonVariant, + onSubmit, + children, + ...drawerProps +}: ConfirmDrawerProps) { + return ( + + {children} + +
+ + + + +
+
+
+ ) +} diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 6ba451c..9d61cb4 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -19,10 +19,11 @@ import { randomAvatar } from "@/utils/dev" import { formatNumber, truncateString } from "@/utils/strings" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { LoaderIcon } from "lucide-react" -import { Suspense, useState } from "react" +import { Suspense, useMemo, useState } from "react" import { Link, useParams } from "react-router" -import { ConfirmDrawer } from "@/components/ConfirmDrawer" -import { Drawer } from "@/components/ui/drawer" +import { BaseDrawer, ConfirmDrawer } from "@/components/Drawers" +import { GrossToNetDiscountForm } from "@/components/GrossToNetDiscountForm" +import Big from "big.js" function Loader() { return ( @@ -32,26 +33,71 @@ function Loader() { ) } -type OfferConfirmDrawerProps = Parameters[0] & { +type OfferFormResult = Parameters[0]["onSubmit"]>[0] + +interface OfferFormProps { + discount: Omit[0], "onSubmit"> + onSubmit: Parameters[0]["onSubmit"] +} + +function OfferForm({ onSubmit, discount }: OfferFormProps) { + return ( + <> + + + ) +} + +type OfferFormDrawerProps = Parameters[0] & { + onSubmit: OfferFormProps["onSubmit"] +} + +function OfferFormDrawer({ onSubmit, children, ...drawerProps }: OfferFormDrawerProps) { + return ( + +
+ +
+
+ ) +} + +type OfferConfirmDrawerProps = Parameters[0] & { onSubmit: () => void - children: React.ReactNode + children?: React.ReactNode } function OfferConfirmDrawer({ children, onSubmit, ...drawerProps }: OfferConfirmDrawerProps) { return ( - -
-
- Are you sure you want to offer the quote? + + <> +
+
+ Are you sure you want to offer the quote? +
-
+ <>{children} + ) } -type DenyConfirmDrawerProps = Parameters[0] & { +type DenyConfirmDrawerProps = Parameters[0] & { onSubmit: () => void - children: React.ReactNode + children?: React.ReactNode } function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDrawerProps) { @@ -73,9 +119,17 @@ function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDr } function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boolean }) { + const [offerFormData, setOfferFormData] = useState() + const [offerFormDrawerOpen, setOfferFormDrawerOpen] = useState(false) const [offerConfirmDrawerOpen, setOfferConfirmDrawerOpen] = useState(false) const [denyConfirmDrawerOpen, setDenyConfirmDrawerOpen] = useState(false) + const effectiveDiscount = useMemo(() => { + if (!offerFormData) return + console.table(offerFormData) + return new Big(1).minus(offerFormData.net.value.div(offerFormData.gross.value)) + }, [offerFormData]) + const queryClient = useQueryClient() const denyQuote = useMutation({ @@ -120,14 +174,14 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) } - const onOfferQuote = () => { + const onOfferQuote = (values: OfferFormResult) => { offerQuote.mutate({ path: { id: value.id, }, body: { action: "offer", - discount: "1", + discount: values.net.value.div(values.gross.value).toFixed(4), ttl: "1", }, }) @@ -152,17 +206,38 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo Deny {denyQuote.isPending && } + { + setOfferFormData(data) + setOfferConfirmDrawerOpen(true) + setOfferFormDrawerOpen(false) + }} + > + + { - onOfferQuote() + if (!offerFormData) return + onOfferQuote(offerFormData) setOfferConfirmDrawerOpen(false) }} > - +
+ + Effective discount: {effectiveDiscount?.mul(new Big("100")).toFixed(2)} + % + + + Net amount: {offerFormData?.net.value.round(0).toFixed(0)}{" "} + {offerFormData?.net.currency} + +
From 198a5d3a84d4e3d8f0bdb06cbd5644f01360aaf5 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Thu, 13 Mar 2025 16:00:53 +0100 Subject: [PATCH 29/41] chore: title --- index.html | 2 +- src/components/nav/NavUser.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 8dd728e..3dc45c5 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - wildcat-dashboard-ui + Wildcat Dashboard
diff --git a/src/components/nav/NavUser.tsx b/src/components/nav/NavUser.tsx index b5c9fd6..82e3be4 100644 --- a/src/components/nav/NavUser.tsx +++ b/src/components/nav/NavUser.tsx @@ -64,17 +64,17 @@ export function NavUser({ - + Account - + Notifications - + Log out From da74fcd055755c3d738b8cffef8739e0e2440315 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Thu, 13 Mar 2025 16:11:20 +0100 Subject: [PATCH 30/41] build(deps): add sonner --- package-lock.json | 22 ++++++++++++++++++++++ package.json | 2 ++ src/components/ui/sonner.tsx | 31 +++++++++++++++++++++++++++++++ src/main.tsx | 2 ++ 4 files changed, 57 insertions(+) create mode 100644 src/components/ui/sonner.tsx diff --git a/package-lock.json b/package-lock.json index ce49a9d..6b7b4ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,11 +35,13 @@ "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.4", "lucide-react": "^0.479.0", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.3.0", "recharts": "^2.15.1", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", @@ -7102,6 +7104,16 @@ "dev": true, "license": "MIT" }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-fetch-native": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz", @@ -8240,6 +8252,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonner": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.1.tgz", + "integrity": "sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 1fe84e0..1faa0b7 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,13 @@ "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.4", "lucide-react": "^0.479.0", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.3.0", "recharts": "^2.15.1", + "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..452f4d9 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/main.tsx b/src/main.tsx index c3cd76a..72bab76 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,6 +13,7 @@ import InfoPage from "./pages/info/InfoPage" import QuotePage from "./pages/quotes/QuotePage" import PendingQuotesPage from "./pages/quotes/PendingQuotesPage" import AcceptedQuotesPage from "./pages/quotes/AcceptedQuotesPage" +import { Toaster } from "./components/ui/sonner" const queryClient = new QueryClient() @@ -42,6 +43,7 @@ void prepare().then(() => { + , ) }) From 3c16fe7551bb2ee9c448907cc76b28655acd13bf Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Thu, 13 Mar 2025 16:20:28 +0100 Subject: [PATCH 31/41] chore: add toast on success/error of quote action --- src/pages/quotes/QuotePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 9d61cb4..f8035cb 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -24,6 +24,7 @@ import { Link, useParams } from "react-router" import { BaseDrawer, ConfirmDrawer } from "@/components/Drawers" import { GrossToNetDiscountForm } from "@/components/GrossToNetDiscountForm" import Big from "big.js" +import { toast } from "sonner" function Loader() { return ( @@ -135,9 +136,11 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo const denyQuote = useMutation({ ...resolveQuoteMutation(), onError: (error) => { - console.log(error) + toast.error("Error while denying quote: " + error.message) + console.warn(error) }, onSuccess: () => { + toast.success("Quote has been denied.") void queryClient.invalidateQueries({ queryKey: adminLookupQuoteQueryKey({ path: { @@ -150,9 +153,11 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo const offerQuote = useMutation({ ...resolveQuoteMutation(), onError: (error) => { - console.log(error) + toast.error("Error while offering quote: " + error.message) + console.warn(error) }, onSuccess: () => { + toast.success("Quote has been offered.") void queryClient.invalidateQueries({ queryKey: adminLookupQuoteQueryKey({ path: { @@ -230,8 +235,12 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo >
- Effective discount: {effectiveDiscount?.mul(new Big("100")).toFixed(2)} - % + Effective discount (relative):{" "} + {effectiveDiscount?.mul(new Big("100")).toFixed(2)}% + + + Effective discount (absolute):{" "} + {offerFormData?.gross.value.minus(offerFormData?.net.value).toFixed(0)} {offerFormData?.net.currency} Net amount: {offerFormData?.net.value.round(0).toFixed(0)}{" "} From 11dc2cc625c65db3a98e9c6b64043d9d761c646f Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Thu, 13 Mar 2025 16:37:41 +0100 Subject: [PATCH 32/41] chore: toast on dev mode settings change --- src/pages/settings/SettingsPage.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 9178ac0..5fabd74 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -5,6 +5,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import useLocalStorage from "@/hooks/use-local-storage" import { Suspense } from "react" +import { toast } from "sonner" function Loader() { return ( @@ -26,6 +27,15 @@ function PageBody() { className="cursor-pointer" checked={devMode} onCheckedChange={() => { + toast.info( + <> + Developer mode is {(!devMode && "ON") || "OFF"} + , + { + id: "settings-dev-mode", + duration: 1_337, + }, + ) setDevMode((it) => !it) }} /> From 1bb414f88c23767c5788d87b25c085fa808bf46e Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Thu, 13 Mar 2025 16:55:52 +0100 Subject: [PATCH 33/41] chore: loading toasts for quote actions --- src/pages/quotes/QuotePage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index f8035cb..fb173ee 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -135,6 +135,9 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo const denyQuote = useMutation({ ...resolveQuoteMutation(), + onSettled: () => { + toast.dismiss(`quote-${value.id}-deny`) + }, onError: (error) => { toast.error("Error while denying quote: " + error.message) console.warn(error) @@ -152,6 +155,9 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) const offerQuote = useMutation({ ...resolveQuoteMutation(), + onSettled: () => { + toast.dismiss(`quote-${value.id}-offer`) + }, onError: (error) => { toast.error("Error while offering quote: " + error.message) console.warn(error) @@ -169,6 +175,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) const onDenyQuote = () => { + toast.loading("Denying quote…", { id: `quote-${value.id}-deny` }) denyQuote.mutate({ path: { id: value.id, @@ -180,6 +187,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo } const onOfferQuote = (values: OfferFormResult) => { + toast.loading("Offering quote…", { id: `quote-${value.id}-offer` }) offerQuote.mutate({ path: { id: value.id, From 55f044636e6b23aad129a6648d389cb92ff1fb7e Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Fri, 14 Mar 2025 10:32:15 +0100 Subject: [PATCH 34/41] chore: drawer title is mandatory --- src/components/Drawers.tsx | 14 ++++++-------- src/pages/quotes/QuotePage.tsx | 17 +++++++++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/components/Drawers.tsx b/src/components/Drawers.tsx index 3ab8bd5..e0e13a0 100644 --- a/src/components/Drawers.tsx +++ b/src/components/Drawers.tsx @@ -13,23 +13,21 @@ import { VariantProps } from "class-variance-authority" type DrawerProps = Parameters[0] type BaseDrawerProps = DrawerProps & { - title?: string + title: string description?: string trigger?: React.ReactNode children?: React.ReactNode } -export function BaseDrawer({ title, description, trigger, children, ...drawerProps }: BaseDrawerProps) { +export function BaseDrawer({ title, description = "", trigger, children, ...drawerProps }: BaseDrawerProps) { return ( {trigger && {trigger}}
- {title && ( - - {title} - {description && {description}} - - )} + + {title} + {description && {description}} + {children}
diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index fb173ee..e21ff0a 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -85,10 +85,8 @@ function OfferConfirmDrawer({ children, onSubmit, ...drawerProps }: OfferConfirm return ( <> -
-
- Are you sure you want to offer the quote? -
+
+ Are you sure you want to offer the quote?
<>{children} @@ -110,10 +108,8 @@ function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDr submitButtonVariant="destructive" onSubmit={onSubmit} > -
-
- Are you sure you want to deny the quote? -
+
+ Are you sure you want to deny offering a quote?
) @@ -204,6 +200,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo <>
{ @@ -220,6 +217,8 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo { @@ -233,6 +232,8 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo { From 2dfdd89e5e14dcf08adf81c25b7e775f886706a4 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Fri, 14 Mar 2025 10:48:13 +0100 Subject: [PATCH 35/41] chore: button label discount form --- src/components/GrossToNetDiscountForm.tsx | 7 ++++--- src/pages/quotes/QuotePage.tsx | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/GrossToNetDiscountForm.tsx b/src/components/GrossToNetDiscountForm.tsx index fff2a21..81f07a5 100644 --- a/src/components/GrossToNetDiscountForm.tsx +++ b/src/components/GrossToNetDiscountForm.tsx @@ -40,6 +40,7 @@ interface CommonDiscountFormProps { type GrossToNetProps = CommonDiscountFormProps & { gross: CurrencyAmount + submitButtonText?: string } interface FormResult { @@ -59,7 +60,7 @@ const INPUT_DAYS_MAX_VALUE = 360 type GrossToNetFormValues = FormValues -const GrossToNetDiscountForm = ({ startDate, endDate, gross, onSubmit }: GrossToNetProps) => { +const GrossToNetDiscountForm = ({ startDate, endDate, gross, onSubmit, submitButtonText = "Submit" }: GrossToNetProps) => { const { watch, register, @@ -94,7 +95,7 @@ const GrossToNetDiscountForm = ({ startDate, endDate, gross, onSubmit }: GrossTo useEffect(() => { if (startDate === undefined) return - setValue("daysInput", String(Math.min(daysBetween(startDate, endDate), INPUT_DAYS_MAX_VALUE)), { + setValue("daysInput", String(Math.min(Math.max(1, daysBetween(startDate, endDate)), INPUT_DAYS_MAX_VALUE)), { shouldValidate: true, shouldDirty: true, shouldTouch: true, @@ -208,7 +209,7 @@ const GrossToNetDiscountForm = ({ startDate, endDate, gross, onSubmit }: GrossTo
) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index e21ff0a..3558485 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -48,24 +48,26 @@ function OfferForm({ onSubmit, discount }: OfferFormProps) { {...discount} startDate={discount.startDate ?? new Date(Date.now())} onSubmit={onSubmit} + submitButtonText="Next" /> ) } type OfferFormDrawerProps = Parameters[0] & { + value: InfoReply onSubmit: OfferFormProps["onSubmit"] } -function OfferFormDrawer({ onSubmit, children, ...drawerProps }: OfferFormDrawerProps) { +function OfferFormDrawer({ value, onSubmit, children, ...drawerProps }: OfferFormDrawerProps) { return (
{ @@ -258,7 +260,6 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo
- ) } From 2273d0cbde9e3c09f1a119f5901fd00bac8a459e Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Fri, 14 Mar 2025 10:59:07 +0100 Subject: [PATCH 36/41] chore: more flexible offer form result --- src/pages/quotes/QuotePage.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 3558485..34aa26b 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -34,11 +34,13 @@ function Loader() { ) } -type OfferFormResult = Parameters[0]["onSubmit"]>[0] +interface OfferFormResult { + discount: Parameters[0]["onSubmit"]>[0] +} interface OfferFormProps { discount: Omit[0], "onSubmit"> - onSubmit: Parameters[0]["onSubmit"] + onSubmit: (result: OfferFormResult) => void } function OfferForm({ onSubmit, discount }: OfferFormProps) { @@ -47,7 +49,9 @@ function OfferForm({ onSubmit, discount }: OfferFormProps) { onSubmit({ + discount: values + })} submitButtonText="Next" /> @@ -62,7 +66,7 @@ type OfferFormDrawerProps = Parameters[0] & { function OfferFormDrawer({ value, onSubmit, children, ...drawerProps }: OfferFormDrawerProps) { return ( -
+
{ if (!offerFormData) return console.table(offerFormData) - return new Big(1).minus(offerFormData.net.value.div(offerFormData.gross.value)) + return new Big(1).minus(offerFormData.discount.net.value.div(offerFormData.discount.gross.value)) }, [offerFormData]) const queryClient = useQueryClient() @@ -184,7 +188,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }) } - const onOfferQuote = (values: OfferFormResult) => { + const onOfferQuote = (result: OfferFormResult) => { toast.loading("Offering quote…", { id: `quote-${value.id}-offer` }) offerQuote.mutate({ path: { @@ -192,7 +196,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo }, body: { action: "offer", - discount: values.net.value.div(values.gross.value).toFixed(4), + discount: result.discount.net.value.div(result.discount.gross.value).toFixed(4), ttl: "1", }, }) @@ -251,11 +255,11 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo Effective discount (absolute):{" "} - {offerFormData?.gross.value.minus(offerFormData?.net.value).toFixed(0)} {offerFormData?.net.currency} + {offerFormData?.discount.gross.value.minus(offerFormData?.discount.net.value).toFixed(0)} {offerFormData?.discount.net.currency} - Net amount: {offerFormData?.net.value.round(0).toFixed(0)}{" "} - {offerFormData?.net.currency} + Net amount: {offerFormData?.discount.net.value.round(0).toFixed(0)}{" "} + {offerFormData?.discount.net.currency}
From f5c2f192f238c7566699085e59a1970605a59cbc Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Fri, 14 Mar 2025 11:15:06 +0100 Subject: [PATCH 37/41] chore: ttl form --- src/components/GrossToNetDiscountForm.tsx | 8 +- src/pages/quotes/QuotePage.tsx | 224 +++++++++++++++------- 2 files changed, 163 insertions(+), 69 deletions(-) diff --git a/src/components/GrossToNetDiscountForm.tsx b/src/components/GrossToNetDiscountForm.tsx index 81f07a5..b5a8eb0 100644 --- a/src/components/GrossToNetDiscountForm.tsx +++ b/src/components/GrossToNetDiscountForm.tsx @@ -60,7 +60,13 @@ const INPUT_DAYS_MAX_VALUE = 360 type GrossToNetFormValues = FormValues -const GrossToNetDiscountForm = ({ startDate, endDate, gross, onSubmit, submitButtonText = "Submit" }: GrossToNetProps) => { +const GrossToNetDiscountForm = ({ + startDate, + endDate, + gross, + onSubmit, + submitButtonText = "Submit", +}: GrossToNetProps) => { const { watch, register, diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 34aa26b..dd50844 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -25,6 +25,7 @@ import { BaseDrawer, ConfirmDrawer } from "@/components/Drawers" import { GrossToNetDiscountForm } from "@/components/GrossToNetDiscountForm" import Big from "big.js" import { toast } from "sonner" +import { useForm } from "react-hook-form" function Loader() { return ( @@ -34,8 +35,78 @@ function Loader() { ) } +interface TimeToLiveFormValues { + ttl?: Date +} + +interface TimeToLiveFormResult { + ttl: Date +} + +interface TimeToLiveFormProps { + submitButtonText?: string + onSubmit: (values: TimeToLiveFormResult) => void +} + +const TimeToLiveForm = ({ onSubmit, submitButtonText = "Submit" }: TimeToLiveFormProps) => { + const { + watch, + register, + handleSubmit, + formState: { isValid, errors }, + } = useForm({ + mode: "all", + }) + + const { ttl } = watch() + + return ( +
{ + handleSubmit(() => { + if (errors.root !== undefined || ttl === undefined) return + + onSubmit({ + ttl, + }) + })(e).catch(() => { + // TODO + }) + }} + > +
+
+ + + +
+
+ + +
+ ) +} + interface OfferFormResult { discount: Parameters[0]["onSubmit"]>[0] + ttl: Parameters[0]["onSubmit"]>[0] } interface OfferFormProps { @@ -44,16 +115,32 @@ interface OfferFormProps { } function OfferForm({ onSubmit, discount }: OfferFormProps) { + const [discountResult, setDiscountResult] = useState() return ( <> - onSubmit({ - discount: values - })} - submitButtonText="Next" - /> + {!discountResult ? ( + <> + + + ) : ( + <> + + onSubmit({ + discount: discountResult, + ttl: ttlResult, + }) + } + submitButtonText="Next" + /> + + )} ) } @@ -203,67 +290,68 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo } return ( -
- { - onDenyQuote() - setDenyConfirmDrawerOpen(false) - }} - > - - - { - setOfferFormData(data) - setOfferConfirmDrawerOpen(true) - setOfferFormDrawerOpen(false) - }} - > - - - { - if (!offerFormData) return - onOfferQuote(offerFormData) - setOfferConfirmDrawerOpen(false) - }} +
+ { + onDenyQuote() + setDenyConfirmDrawerOpen(false) + }} + > +
+ Deny {denyQuote.isPending && } + + + { + setOfferFormData(data) + setOfferConfirmDrawerOpen(true) + setOfferFormDrawerOpen(false) + }} + > + + + { + if (!offerFormData) return + onOfferQuote(offerFormData) + setOfferConfirmDrawerOpen(false) + }} + > +
+ + Effective discount (relative):{" "} + {effectiveDiscount?.mul(new Big("100")).toFixed(2)}% + + + Effective discount (absolute):{" "} + {offerFormData?.discount.gross.value.minus(offerFormData?.discount.net.value).toFixed(0)}{" "} + {offerFormData?.discount.net.currency} + + + Net amount: {offerFormData?.discount.net.value.round(0).toFixed(0)}{" "} + {offerFormData?.discount.net.currency} + +
+
+
) } From e1c420f6d3c2b582e2a54115640d77ffdc0bf8ca Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Fri, 14 Mar 2025 11:16:32 +0100 Subject: [PATCH 38/41] chore: add calendar --- package-lock.json | 15 +++++++ package.json | 1 + src/components/ui/calendar.tsx | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/components/ui/calendar.tsx diff --git a/package-lock.json b/package-lock.json index 6b7b4ee..bac1160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "lucide-react": "^0.479.0", "next-themes": "^0.4.6", "react": "^19.0.0", + "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.3.0", @@ -7609,6 +7610,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "19.0.0", "license": "MIT", diff --git a/package.json b/package.json index 1faa0b7..5cd3e7d 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "lucide-react": "^0.479.0", "next-themes": "^0.4.6", "react": "^19.0.0", + "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", "react-router": "^7.3.0", diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..3976ece --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,73 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: React.ComponentProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "size-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: + "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground", + day_range_end: + "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ) +} + +export { Calendar } From 8b84790f73a9bd586b6bc4b7baaa9ecf69cdbe57 Mon Sep 17 00:00:00 2001 From: mjkeaton Date: Fri, 14 Mar 2025 12:23:43 +0100 Subject: [PATCH 39/41] chore: better ttl form --- package-lock.json | 31 +++- package.json | 4 +- src/components/GrossToNetDiscountForm.tsx | 24 +--- src/components/InputContainer.tsx | 24 ++++ src/components/ui/form.tsx | 165 ++++++++++++++++++++++ src/pages/quotes/QuotePage.tsx | 37 ++--- 6 files changed, 245 insertions(+), 40 deletions(-) create mode 100644 src/components/InputContainer.tsx create mode 100644 src/components/ui/form.tsx diff --git a/package-lock.json b/package-lock.json index bac1160..2218203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@hey-api/client-fetch": "^0.8.3", + "@hookform/resolvers": "^4.1.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -46,7 +47,8 @@ "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -1190,6 +1192,18 @@ "typescript": "^5.5.3" } }, + "node_modules/@hookform/resolvers": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz", + "integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "license": "Apache-2.0", @@ -2643,6 +2657,12 @@ "linux" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.0.13", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.13.tgz", @@ -9744,6 +9764,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 5cd3e7d..0aa1161 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@hey-api/client-fetch": "^0.8.3", + "@hookform/resolvers": "^4.1.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -54,7 +55,8 @@ "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.13", "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/src/components/GrossToNetDiscountForm.tsx b/src/components/GrossToNetDiscountForm.tsx index b5a8eb0..e7e5173 100644 --- a/src/components/GrossToNetDiscountForm.tsx +++ b/src/components/GrossToNetDiscountForm.tsx @@ -1,31 +1,11 @@ -import { LabelHTMLAttributes, PropsWithChildren, useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useForm } from "react-hook-form" import Big from "big.js" -import { cn } from "@/lib/utils" import { parseFloatSafe, parseIntSafe } from "@/utils/numbers" import { daysBetween } from "@/utils/dates" import { Act360 } from "@/utils/discount-util" import { Button } from "./ui/button" - -type InputContainerProps = PropsWithChildren<{ - htmlFor: LabelHTMLAttributes["htmlFor"] - label: React.ReactNode -}> - -const InputContainer = ({ children, htmlFor, label }: InputContainerProps) => { - return ( -
- - {children} -
- ) -} +import { InputContainer } from "./InputContainer" interface CurrencyAmount { value: Big diff --git a/src/components/InputContainer.tsx b/src/components/InputContainer.tsx new file mode 100644 index 0000000..ada6110 --- /dev/null +++ b/src/components/InputContainer.tsx @@ -0,0 +1,24 @@ +import { LabelHTMLAttributes, PropsWithChildren } from "react" +import { cn } from "@/lib/utils" + +type InputContainerProps = PropsWithChildren<{ + htmlFor: LabelHTMLAttributes["htmlFor"] + label: React.ReactNode +}> + +const InputContainer = ({ children, htmlFor, label }: InputContainerProps) => { + return ( +
+ + {children} +
+ ) +} + +export { InputContainer } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..7d7474c --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,165 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +