diff --git a/.env.development b/.env.development index f76352f..ed39307 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,4 @@ VITE_API_BASE_URL="http://localhost:4242" -VITE_API_MOCKING_ENABLED=false VITE_KEYCLOAK_URL="http://localhost:8080/" VITE_KEYCLOAK_REALM="dev" diff --git a/.env.production b/.env.production index d98ad3c..16afe7a 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1 @@ VITE_API_BASE_URL=http://127.0.0.1:3338 -VITE_API_MOCKING_ENABLED=false diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index afd8a9a..c141a88 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,9 +1,8 @@ -import { Bitcoin, Home, Inbox, Settings, InfoIcon, LifeBuoy, Send, TrendingUpIcon } from "lucide-react" +import { Bitcoin, Home, Inbox } 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" +import { useKeycloak } from "../lib/keycloak-user" const data = { navMain: [ @@ -17,11 +16,6 @@ const data = { url: "/balances", icon: Bitcoin, }, - { - title: "Earnings", - url: "/earnings", - icon: TrendingUpIcon, - }, { title: "Quotes", url: "/quotes", @@ -47,60 +41,20 @@ const data = { title: "Rejected", url: "/quotes/rejected", }, - /*{ - title: "Expired", - url: "/quotes/expired", - },*/ ], }, - { - title: "Settings", - url: "/settings", - 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() { + const { user, isLoading } = useKeycloak() + return ( - - - - + {!isLoading && user && } ) diff --git a/src/components/nav/NavUser.tsx b/src/components/nav/NavUser.tsx index 82e3be4..7470aae 100644 --- a/src/components/nav/NavUser.tsx +++ b/src/components/nav/NavUser.tsx @@ -23,6 +23,8 @@ export function NavUser({ }) { const { isMobile } = useSidebar() + const initials = user.name.length > 0 ? user.name[0].toUpperCase() : "U" + return ( @@ -31,14 +33,16 @@ export function NavUser({ - - {user.name} +
+ {initials} +
+
- {user.name} + {user.name || "Unknown User"} {user.email}
@@ -57,7 +61,7 @@ export function NavUser({ CN
- {user.name} + {user.name || "Unknown User"} {user.email}
diff --git a/src/generated/client/@tanstack/react-query.gen.ts b/src/generated/client/@tanstack/react-query.gen.ts index 34a9ead..f074973 100644 --- a/src/generated/client/@tanstack/react-query.gen.ts +++ b/src/generated/client/@tanstack/react-query.gen.ts @@ -1,8 +1,8 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type Options, listQuotes, listPendingQuotes, adminLookupQuote, adminUpdateQuote, resolveOffer, enquireQuote, lookupQuote } from '../sdk.gen'; +import { type Options, listQuotes, listPendingQuotes, adminLookupQuote, adminUpdateQuote, resolveOffer, enquireQuote, lookupQuote, debitBalance, creditBalance } from '../sdk.gen'; import { queryOptions, type UseMutationOptions, type DefaultError } from '@tanstack/react-query'; -import type { ListQuotesData, ListPendingQuotesData, AdminLookupQuoteData, AdminUpdateQuoteData, AdminUpdateQuoteResponse, ResolveOfferData, EnquireQuoteData, EnquireQuoteResponse, LookupQuoteData } from '../types.gen'; +import type { ListQuotesData, ListPendingQuotesData, AdminLookupQuoteData, AdminUpdateQuoteData, AdminUpdateQuoteResponse, ResolveOfferData, EnquireQuoteData, EnquireQuoteResponse, LookupQuoteData, DebitData} from '../types.gen'; import { client as _heyApiClient } from '../client.gen'; export type QueryKey = [ @@ -195,4 +195,38 @@ export const lookupQuoteOptions = (options: Options) => { }, queryKey: lookupQuoteQueryKey(options) }); -}; \ No newline at end of file +}; + +export const debitBalanceQueryKey = (options?: Options) => createQueryKey('debitBalance', options); + +export const debitBalanceOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await debitBalance({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: debitBalanceQueryKey(options) + }); +}; + +export const creditBalanceQueryKey = (options?: Options) => createQueryKey('creditBalance', options); + +export const creditBalanceOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await creditBalance({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: creditBalanceQueryKey(options) + }); +}; diff --git a/src/generated/client/sdk.gen.ts b/src/generated/client/sdk.gen.ts index 3f43559..e4d1df7 100644 --- a/src/generated/client/sdk.gen.ts +++ b/src/generated/client/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { ListQuotesData, ListQuotesResponse, ListPendingQuotesData, ListPendingQuotesResponse, AdminLookupQuoteData, AdminLookupQuoteResponse, AdminUpdateQuoteData, AdminUpdateQuoteResponse, ResolveOfferData, EnquireQuoteData, EnquireQuoteResponse, LookupQuoteData, LookupQuoteResponse, ActivateKeysetData, ActivateKeysetResponse } from './types.gen'; +import type { ListQuotesData, ListQuotesResponse, ListPendingQuotesData, ListPendingQuotesResponse, AdminLookupQuoteData, AdminLookupQuoteResponse, AdminUpdateQuoteData, AdminUpdateQuoteResponse, ResolveOfferData, EnquireQuoteData, EnquireQuoteResponse, LookupQuoteData, LookupQuoteResponse, ActivateKeysetData, ActivateKeysetResponse, DebitData, CreditData, ECashBalance, OnChainBalanceData, OnChainData} from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -98,4 +98,30 @@ export const activateKeyset = (options: Op ...options?.headers } }); -}; \ No newline at end of file +}; + + +/** + * --------------------------- Balance Check +*/ + +export const debitBalance = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/v1/admin/balance/debit', + ...options + }); +}; + +export const creditBalance = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/v1/admin/balance/credit', + ...options + }); +}; + +export const onchainBalance = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/v1/admin/onchain/balance', + ...options + }); +}; diff --git a/src/generated/client/types.gen.ts b/src/generated/client/types.gen.ts index 8789e83..5e0a1be 100644 --- a/src/generated/client/types.gen.ts +++ b/src/generated/client/types.gen.ts @@ -27,6 +27,14 @@ export type ListReplyLight = { */ export type Amount = number; + +export type PayeePublicData = { + Ident: IdentityPublicData; +} | { + Anon: AnonPublicData; +}; + + /** * --------------------------- Enquire mint quote */ @@ -36,7 +44,7 @@ export type BillInfo = { endorsees: Array; id: string; maturity_date: string; - payee: IdentityPublicData; + payee: PayeePublicData; sum: number; }; @@ -109,6 +117,12 @@ export type IdentityPublicData = PostalAddress & { type: ContactType; }; +export type AnonPublicData = { + node_id: string; + email?: string | null; + nostr_relays: Array; +} + /** * --------------------------- Quote info request */ @@ -405,4 +419,46 @@ export type ActivateKeysetResponses = { 200: unknown; }; -export type ActivateKeysetResponse = ActivateKeysetResponses[keyof ActivateKeysetResponses]; \ No newline at end of file +export type ActivateKeysetResponse = ActivateKeysetResponses[keyof ActivateKeysetResponses]; + +/** + * Currency unit used for ECash + */ +export type CurrencyUnit = string; + +/** + * ECash balance information + */ +export type ECashBalance = { + amount: Amount; + unit: CurrencyUnit; +}; + +export type DebitData = { + body?: never; + path?: never; + query?: never; + url: '/v1/admin/balance/debit'; +}; + +export type CreditData = { + body?: never; + path?: never; + query?: never; + url: '/v1/admin/balance/credit'; +}; + +export type OnChainBalanceData = { + immature: number; + trusted_pending: number; + untrusted_pending: number; + confirmed: number; +}; + + +export type OnChainData = { + body?: never; + path?: never; + query?: never; + url: '/v1/admin/onchain/balance'; +}; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 71de74c..e3464d4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -23,5 +23,17 @@ heyApiClient.interceptors.request.use((request) => { return request }) +const originalFetch = window.fetch +window.fetch = async function (...args) { + try { + console.log("Refreshing token...") + await keycloak.updateToken(30) + } catch (error) { + console.error("Failed to refresh token:", error) + } + + return originalFetch.apply(this, args) +} + export const client = heyApiClient export { sdk } diff --git a/src/lib/keycloak-user.ts b/src/lib/keycloak-user.ts new file mode 100644 index 0000000..562355d --- /dev/null +++ b/src/lib/keycloak-user.ts @@ -0,0 +1,90 @@ +import { useState, useEffect } from "react" +import keycloak from "../keycloak" + +interface KeycloakProfile { + username?: string + email?: string +} + +interface KeycloakTokenParsed { + preferred_username?: string + email?: string +} + +interface KeycloakUser { + name: string + email: string + avatar: string +} + +interface UseKeycloakReturn { + isAuthenticated: boolean + isLoading: boolean + user: KeycloakUser | null +} + +export function useKeycloak(): UseKeycloakReturn { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [user, setUser] = useState(null) + + useEffect(() => { + const updateAuthState = () => { + if (keycloak.authenticated) { + setIsAuthenticated(true) + setUser({ + name: (keycloak.profile as KeycloakProfile)?.username ?? (keycloak.tokenParsed as KeycloakTokenParsed)?.preferred_username ?? 'Unknown User', + email: (keycloak.profile as KeycloakProfile)?.email ?? (keycloak.tokenParsed as KeycloakTokenParsed)?.email ?? '', + avatar: "", + }) + } else { + setIsAuthenticated(false) + setUser(null) + } + setIsLoading(false) + } + + if (keycloak.authenticated) { + updateAuthState() + } + + keycloak.onAuthSuccess = () => { + updateAuthState() + } + + keycloak.onAuthError = () => { + setIsAuthenticated(false) + setUser(null) + setIsLoading(false) + } + + keycloak.onAuthLogout = () => { + setIsAuthenticated(false) + setUser(null) + setIsLoading(false) + } + + if (!keycloak.authenticated && !keycloak.loginRequired) { + const checkAuth = () => { + if (keycloak.authenticated || keycloak.loginRequired) { + updateAuthState() + } else { + setTimeout(checkAuth, 100) + } + } + checkAuth() + } + + return () => { + keycloak.onAuthSuccess = undefined + keycloak.onAuthError = undefined + keycloak.onAuthLogout = undefined + } + }, []) + + return { + isAuthenticated, + isLoading, + user, + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..12ed9a2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const getInitials = (name: string | undefined): string => { + if (!name) return "" + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0].toUpperCase()) + .join("") +} diff --git a/src/main.tsx b/src/main.tsx index da7b45d..c145461 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,38 +5,25 @@ import "./index.css" import Layout from "./layout" import HomePage from "./pages/home/HomePage" import BalancesPage from "./pages/balances/BalancesPage" -import QuotesPage from "./pages/quotes/QuotesPage" import SettingsPage from "./pages/settings/SettingsPage" -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" +import StatusQuotePage from "./pages/quotes/StatusQuotePage" import { Toaster } from "./components/ui/sonner" import EarningsPage from "./pages/balances/EarningsPage" import CashFlowPage from "./pages/balances/CashFlowPage" -import OfferedQuotesPage from "./pages/quotes/OfferedQuotesPage" -import DeniedQuotesPage from "./pages/quotes/DeniedQuotesPage" -// import ExpiredQuotesPage from "./pages/quotes/ExpiredQuotesPage" -import RejectedQuotesPage from "./pages/quotes/RejectedQuotesPage" import { initKeycloak } from "./keycloak" import "./lib/api-client" -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - enabled: (query) => query.queryKey[0] !== "balances", - }, - }, -}) +const queryClient = new QueryClient() const prepare = async () => { await initKeycloak() - if (meta.apiMocksEnabled) { - const { worker } = await import("./mocks/browser") - await worker.start() - } + // if (meta.apiMocksEnabled) { + // const { worker } = await import("./mocks/browser") + // await worker.start() + // } } function App() { @@ -49,13 +36,13 @@ function App() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* } /> */} + } /> + } /> + } /> + } /> + } /> + } /> + {/* } /> */} } /> } /> } /> diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts deleted file mode 100644 index 43acdac..0000000 --- a/src/mocks/browser.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { setupWorker } from "msw/browser" -import { handlers } from "./handlers" -import { scenarios } from "./scenarios" - -const scenarioName = new URLSearchParams(window.location.search).get("scenario") as unknown as keyof typeof scenarios - -const runtimeScenarios = scenarios[scenarioName] || [] - -export const worker = setupWorker(...runtimeScenarios, ...handlers) diff --git a/src/mocks/db.ts b/src/mocks/db.ts deleted file mode 100644 index d6f4df4..0000000 --- a/src/mocks/db.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { factory, manyOf, nullable, oneOf, primaryKey } from "@mswjs/data" -import { faker } from "@faker-js/faker" - -// Seed `faker` to ensure reproducible random values of model properties. -faker.seed(21_000_000) - -export const db = factory({ - info: { - id: primaryKey(String), - name: nullable(String), - pubkey: nullable(String), - version: nullable(String), - }, - quotes: { - id: primaryKey(String), - bill: oneOf("bill"), - status: nullable(String), - submitted: nullable(String), // pending - suggested_expiration: nullable(String), // pending - ttl: nullable(String), // offered - signatures: Array, // accepted - tstamp: nullable(String), // rejected - }, - bill: { - id: primaryKey(String), - drawee: nullable(oneOf("identity_public_data")), - drawer: nullable(oneOf("identity_public_data")), - endorsees: nullable(manyOf("identity_public_data")), - payee: nullable(oneOf("identity_public_data")), - sum: Number, - maturity_date: String, - }, - identity_public_data: { - node_id: primaryKey(String), - name: String, - email: nullable(String), - nostr_relay: nullable(String), - type: String, - address: String, - city: String, - country: String, - zip: nullable(String), - }, -}) - -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", - email: faker.internet.exampleEmail({ firstName: "Alice" }), - type: "Person", - address: faker.location.streetAddress(), - city: faker.location.city(), - country: faker.location.country(), - zip: faker.location.zipCode(), -}) - -const BOB = db.identity_public_data.create({ - node_id: "03ebc85dd13b60a850a3274b367acd25a8c12e7c348428a1981212a5d556a746de", - name: "Bob", - email: faker.internet.exampleEmail({ firstName: "Bob" }), - type: "Person", - address: faker.location.streetAddress(), - city: faker.location.city(), - country: faker.location.country(), - zip: faker.location.zipCode(), -}) - -const CHARLIE = db.identity_public_data.create({ - node_id: "035547a7c0c8638b2fe708eefa3fe6b51612d926ac209e009f49da37b25d558a36", - name: `Charlie's ${faker.company.name()}`, - email: faker.internet.exampleEmail({ firstName: "Charlie" }), - type: "Company", - address: faker.location.streetAddress(), - city: faker.location.city(), - country: faker.location.country(), - zip: faker.location.zipCode(), -}) - -const AMOUNT_OF_BILLS = 100 -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.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, // payee: CHARLIE - endorsees: [CHARLIE], - }), -) - -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(), - status: "pending", - bill, - }), -) - -const OFFERED_BILLS = BILLS.slice(5, 10) -OFFERED_BILLS.forEach((bill) => - db.quotes.create({ - id: faker.string.uuid(), - status: "offered", - bill, - }), -) - -const REJECTED_BILLS = BILLS.slice(10, 15) -REJECTED_BILLS.forEach((bill) => - db.quotes.create({ - id: faker.string.uuid(), - status: "rejected", - bill, - }), -) - -const ACCEPTED_BILLS = BILLS.slice(15, 30) -ACCEPTED_BILLS.forEach((bill) => { - return db.bill.update({ - where: { id: { equals: bill.id } }, - data: { - ...bill, - endorsees: [MINT], - payee: MINT, - }, - }) -}) - -ACCEPTED_BILLS.forEach((bill) => { - db.quotes.create({ - id: faker.string.uuid(), - status: "accepted", - bill, - }) -}) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts deleted file mode 100644 index 13068fd..0000000 --- a/src/mocks/handlers.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - fetchAdminQuote, - fetchAdminLookupQuote, - fetchAdminQuotePending, - updateAdminQuote, -} from "./handlers/admin_quotes" -import { fetchBalances } from "./handlers/balances" -import { fetchInfo } from "./handlers/info" - -export const handlers = [ - fetchInfo, - fetchBalances, - fetchAdminQuote, - fetchAdminQuotePending, - fetchAdminLookupQuote, - updateAdminQuote, -] diff --git a/src/mocks/handlers/admin_quotes.ts b/src/mocks/handlers/admin_quotes.ts deleted file mode 100644 index 9b4c09a..0000000 --- a/src/mocks/handlers/admin_quotes.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { http, delay, HttpResponse } from "msw" -import { API_URL } from "@/constants/api" -import { ADMIN_QUOTE_BY_ID, ADMIN_QUOTE_PENDING, ADMIN_QUOTE } from "@/constants/endpoints" -import { - AdminLookupQuoteResponse, - InfoReply, - ListPendingQuotesResponse, - ListReplyLight, - UpdateQuoteRequest, - UpdateQuoteResponse, -} from "@/generated/client" -import { db } from "../db" - -export const fetchAdminQuotePending = http.get( - `${API_URL}${ADMIN_QUOTE_PENDING}`, - async () => { - await delay(1_000) - - const data = db.quotes.getAll().filter((it) => it.status === "pending") - - return HttpResponse.json({ - quotes: data.map((it) => it.id), - }) - }, -) - -export const fetchAdminQuote = http.get( - `${API_URL}${ADMIN_QUOTE}`, - async ({ request }) => { - const url = new URL(request.url) - await delay(1_000) - let data = db.quotes.getAll() - - const states = url.searchParams.getAll("status") - if (states.length !== 0) { - data = data.filter((it) => states.includes(it.status ?? "")) - } - - return HttpResponse.json({ - quotes: data.map((it) => ({ - id: it.id, - status: it.status ?? undefined, - })), - }) - }, -) - -export const fetchAdminLookupQuote = http.get( - `${API_URL}${ADMIN_QUOTE_BY_ID}`, - async ({ params }) => { - const { id } = params - - await delay(1_000) - - const data = db.quotes.getAll().filter((it) => it.id === id) - if (data.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return HttpResponse.json(null, { status: 404 }) - } - - return HttpResponse.json(data[0] as InfoReply) - }, -) - -export const updateAdminQuote = http.post( - `${API_URL}${ADMIN_QUOTE_BY_ID}`, - async ({ params, request }) => { - const { id } = params - const body = await request.json() - - await delay(1_000) - - const data = db.quotes.getAll().filter((it) => it.id === id) - if (data.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return HttpResponse.json(null, { status: 404 }) - } - - const quote = data[0] - - if (body.action === "Deny") { - quote.status = "denied" - } - 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({ - where: { id: { equals: quote.id } }, - data: quote, - }) - - return HttpResponse.json(updated as UpdateQuoteResponse) - }, -) diff --git a/src/mocks/handlers/balances.ts b/src/mocks/handlers/balances.ts deleted file mode 100644 index 3c0bcd2..0000000 --- a/src/mocks/handlers/balances.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { http, delay, HttpResponse } from "msw" -import { API_URL } from "@/constants/api" -import type { BalancesResponse } from "@/lib/api" -import { BALANCES } from "@/constants/endpoints" - -export const fetchBalances = http.get(`${API_URL}${BALANCES}`, async () => { - await delay(1_000) - - return HttpResponse.json({ - bitcoin: { - value: "42.12345678", - currency: "BTC", - }, - eiou: { - value: "1.12345678", - currency: "BTC", - }, - debit: { - value: "0.12345678", - currency: "BTC", - }, - credit: { - value: "0.00000042", - currency: "BTC", - }, - }) -}) diff --git a/src/mocks/handlers/info.ts b/src/mocks/handlers/info.ts deleted file mode 100644 index bab23c3..0000000 --- a/src/mocks/handlers/info.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { http, delay, HttpResponse } from "msw" -import { API_URL } from "@/constants/api" -import type { InfoResponse } from "@/lib/api" -import { INFO } from "@/constants/endpoints" - -export const fetchInfo = http.get(`${API_URL}${INFO}`, async () => { - await delay(1_000) - - return HttpResponse.json({ - name: "Bob's Wildcat mint", - pubkey: "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99", - version: "Nutshell/0.15.0", - description: "The short mint description", - description_long: "A description that can be a long piece of text.", - contact: [ - { - method: "email", - info: "contact@me.com", - }, - { - method: "twitter", - info: "@me", - }, - { - method: "nostr", - info: "npub...", - }, - ], - motd: "Message to display to users.", - icon_url: "https://mint.host/icon.jpg", - urls: ["https://mint.host", "http://mint8gv0sq5ul602uxt2fe0t80e3c2bi9fy0cxedp69v1vat6ruj81wv.onion"], - time: 1725304480, - nuts: { - "4": { - methods: [ - { - method: "bolt11", - unit: "sat", - min_amount: 0, - max_amount: 10000, - }, - ], - disabled: false, - }, - "5": { - methods: [ - { - method: "bolt11", - unit: "sat", - min_amount: 100, - max_amount: 10000, - }, - ], - disabled: false, - }, - "7": { - supported: true, - }, - "8": { - supported: true, - }, - "9": { - supported: true, - }, - "10": { - supported: true, - }, - "12": { - supported: true, - }, - }, - }) -}) diff --git a/src/mocks/handlers/utils.ts b/src/mocks/handlers/utils.ts deleted file mode 100644 index a5ed028..0000000 --- a/src/mocks/handlers/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const matchesSearchTerm = (it: Record, search_term: string | undefined) => { - return !search_term - ? true - : Object.entries(it).some(([, value]) => { - if (value !== null && typeof value === "object") { - return Object.values(value).some( - (innerValue) => - typeof innerValue === "string" && innerValue.toLowerCase().includes(search_term.toLowerCase()), - ) - } - return typeof value === "string" && value.toLowerCase().includes(search_term.toLowerCase()) - }) -} diff --git a/src/mocks/scenarios.ts b/src/mocks/scenarios.ts deleted file mode 100644 index ad20961..0000000 --- a/src/mocks/scenarios.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const scenarios = { - empty: [], -} diff --git a/src/pages/balances/BalancesPage.tsx b/src/pages/balances/BalancesPage.tsx index 10c977d..a9f2e88 100644 --- a/src/pages/balances/BalancesPage.tsx +++ b/src/pages/balances/BalancesPage.tsx @@ -1,13 +1,21 @@ import { PropsWithChildren, Suspense } from "react" +import { useQueries } from "@tanstack/react-query" import { Breadcrumbs } from "@/components/Breadcrumbs" 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 { 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 { debitBalance, creditBalance, onchainBalance } from "@/generated/client/sdk.gen" + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Request timeout after ${timeoutMs}ms`)), timeoutMs), + ), + ]) +} function Loader() { return ( @@ -120,86 +128,165 @@ export function OtherBalanceChart() { ) } -export function BalanceText({ value, children }: PropsWithChildren<{ value: BalancesResponse["bitcoin"] }>) { +interface BalanceDisplay { + amount: string + unit: string +} + +export function BalanceText({ amount, unit, children }: PropsWithChildren) { return ( <>

- {value.value} {value.currency} + {amount} {unit}

{children} ) } -function PageBody() { - const { data } = useSuspenseQuery({ - queryKey: ["balances"], - queryFn: fetchBalances, +function useBalances() { + const queries = useQueries({ + queries: [ + { + queryKey: ["balance", "credit"], + queryFn: async () => { + const response = await withTimeout(creditBalance({}), 10_000) + if (response.error) { + throw new Error("Failed to fetch credit balance") + } + return response.data + }, + refetchInterval: 30_000, + staleTime: 25_000, + retry: 2, + gcTime: 10_000, + }, + { + queryKey: ["balance", "debit"], + queryFn: async () => { + const response = await withTimeout(debitBalance({}), 10_000) + if (response.error) { + throw new Error("Failed to fetch debit balance") + } + return response.data + }, + refetchInterval: 30_000, + staleTime: 25_000, + retry: 2, + gcTime: 10_000, + }, + { + queryKey: ["balance", "onchain"], + queryFn: async () => { + const response = await withTimeout(onchainBalance({}), 10_000) + if (response.error) { + throw new Error("Failed to fetch onchain balance") + } + return response.data + }, + refetchInterval: 30_000, + staleTime: 25_000, + retry: 2, + gcTime: 10_000, + }, + ], }) - return ( -
-
- - - Bitcoin balance - - - - - - - - e-IOU balance - - - - - - - - Credit token balance - - - - - - - - Debit token balance - - - - - -
+ const [creditQuery, debitQuery, onchainQuery] = queries -
- - - - - - -
-
- ) + const hasError = queries.some((query) => query.isError) + const error = hasError ? "Failed to load one or more balances" : null + + const balances: Record = { + bitcoin: { + amount: + onchainQuery.data && typeof onchainQuery.data === "object" && "confirmed" in onchainQuery.data + ? String(onchainQuery.data.confirmed) + : "0", + unit: "BTC", + }, + eiou: { amount: "0", unit: "eIOU" }, + credit: { + amount: creditQuery.data && "amount" in creditQuery.data ? String(creditQuery.data.amount) : "0", + unit: creditQuery.data && "unit" in creditQuery.data ? String(creditQuery.data.unit) : "credit", + }, + debit: { + amount: debitQuery.data && "amount" in debitQuery.data ? String(debitQuery.data.amount) : "0", + unit: debitQuery.data && "unit" in debitQuery.data ? String(debitQuery.data.unit) : "debit", + }, + } + + const refetch = () => { + void Promise.all(queries.map((query) => query.refetch())) + } + + return { balances, error, refetch } } -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - const { data } = useSuspenseQuery({ - queryKey: ["balances"], - queryFn: fetchBalances, - }) +function PageBodyWithDevSection() { + const { balances, error } = useBalances() + + if (error) { + return ( + <> +
+ + +

Error loading balances: {error}

+
+
+
+ + ) + } return ( <> - {devMode && ( -
-          {JSON.stringify(data, null, 2)}
-        
- )} +
+
+ + + Bitcoin balance + + + + + + + + e-IOU balance + + + + + + + + Credit token balance + + + + + + + + Debit token balance + + + + + +
+ +
+ + + + + + +
+
) } @@ -211,8 +298,7 @@ export default function BalancesPage() { Balances }> - - + ) diff --git a/src/pages/quotes/AcceptedQuotesPage.tsx b/src/pages/quotes/AcceptedQuotesPage.tsx deleted file mode 100644 index bd281cc..0000000 --- a/src/pages/quotes/AcceptedQuotesPage.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Breadcrumbs } from "@/components/Breadcrumbs" -import { PageTitle } from "@/components/PageTitle" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { ListQuotesData } from "@/generated/client" -import { listQuotesOptions } 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({ - ...listQuotesOptions({ - query: { - status: "Accepted", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> -
- -
- -
- {data.quotes.length === 0 &&
No accepted quotes.
} - {data.quotes.map((it, index) => { - return ( -
- - {isFetching ? ( - <>{it.id} - ) : ( - <> - {it.id} - - )} - - - -
- ) - })} -
- - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesAccepted } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Accepted", - } as unknown as ListQuotesData["query"], - }), - }) - - 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/DeniedQuotesPage.tsx b/src/pages/quotes/DeniedQuotesPage.tsx deleted file mode 100644 index 2dcea4d..0000000 --- a/src/pages/quotes/DeniedQuotesPage.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Breadcrumbs } from "@/components/Breadcrumbs" -import { PageTitle } from "@/components/PageTitle" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { ListQuotesData } from "@/generated/client" -import { listQuotesOptions } 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 QuoteListDenied() { - const navigate = useNavigate() - - const { data, isFetching } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Denied", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> -
- -
- -
- {data.quotes.length === 0 &&
No denied quotes.
} - {data.quotes.map((it, index) => { - return ( -
- - {isFetching ? ( - <>{it.id} - ) : ( - <> - {it.id} - - )} - - - -
- ) - })} -
- - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesDenied } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Denied", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesDenied, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { - return ( -
-
- -
-
- ) -} - -export default function DeniedQuotesPage() { - return ( - <> - - Quotes - , - ]} - > - Denied - - Denied Quotes - }> - - - - - - - ) -} diff --git a/src/pages/quotes/ExpiredQuotesPage.tsx b/src/pages/quotes/ExpiredQuotesPage.tsx deleted file mode 100644 index c1e62c3..0000000 --- a/src/pages/quotes/ExpiredQuotesPage.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Breadcrumbs } from "@/components/Breadcrumbs" -import { PageTitle } from "@/components/PageTitle" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { ListQuotesData } from "@/generated/client" -import { listQuotesOptions } 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 QuoteListExpired() { - const navigate = useNavigate() - - const { data, isFetching } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Expired", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> -
- -
- -
- {data.quotes.length === 0 &&
No expired quotes.
} - {data.quotes.map((it, index) => { - return ( -
- - {isFetching ? ( - <>{it.id} - ) : ( - <> - {it.id} - - )} - - - -
- ) - })} -
- - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesExpired } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Expired", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesExpired, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { - return ( -
-
- -
-
- ) -} - -export default function ExpiredQuotesPage() { - return ( - <> - - Quotes - , - ]} - > - Expired - - Expired Quotes - }> - - - - - - - ) -} diff --git a/src/pages/quotes/OfferedQuotesPage.tsx b/src/pages/quotes/OfferedQuotesPage.tsx deleted file mode 100644 index 0c07592..0000000 --- a/src/pages/quotes/OfferedQuotesPage.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Breadcrumbs } from "@/components/Breadcrumbs" -import { PageTitle } from "@/components/PageTitle" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { ListQuotesData } from "@/generated/client" -import { listQuotesOptions } 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 QuoteListOffered() { - const navigate = useNavigate() - - const { data, isFetching } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Offered", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> -
- -
- -
- {data.quotes.length === 0 &&
No offered quotes.
} - {data.quotes.map((it, index) => { - return ( -
- - {isFetching ? ( - <>{it.id} - ) : ( - <> - {it.id} - - )} - - - -
- ) - })} -
- - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesOffered } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Offered", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesOffered, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { - return ( -
-
- -
-
- ) -} - -export default function OfferedQuotesPage() { - return ( - <> - - Quotes - , - ]} - > - Offered - - Offered Quotes - }> - - - - - - - ) -} diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 9bb130e..59f30b1 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -1,24 +1,25 @@ import { Breadcrumbs } from "@/components/Breadcrumbs" import { PageTitle } from "@/components/PageTitle" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Avatar } from "@/components/ui/avatar" 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 { IdentityPublicData, PayeePublicData, InfoReply, AnonPublicData } from "@/generated/client" import { adminLookupQuoteOptions, adminLookupQuoteQueryKey, adminUpdateQuoteMutation, } from "@/generated/client/@tanstack/react-query.gen" import { activateKeyset } from "@/generated/client/sdk.gen" -import useLocalStorage from "@/hooks/use-local-storage" -import { cn } from "@/lib/utils" +import { cn, getInitials } from "@/lib/utils" 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" +import { getDeterministicColor } from "@/utils/dev" + import { LoaderIcon } from "lucide-react" import { Suspense, useMemo, useState } from "react" import { Link, useParams } from "react-router" @@ -411,7 +412,7 @@ function QuoteActions({ value, isFetching }: { value: InfoReply; isFetching: boo trigger={ - - ) - })} - - - ) -} - -function DevSection() { - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesRejected } = useSuspenseQuery({ - ...listQuotesOptions({ - query: { - status: "Rejected", - } as unknown as ListQuotesData["query"], - }), - }) - - return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesRejected, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { - return ( -
-
- -
-
- ) -} - -export default function RejectedQuotesPage() { - return ( - <> - - Quotes - , - ]} - > - Rejected - - Rejected Quotes - }> - - - - - - - ) -} diff --git a/src/pages/quotes/PendingQuotesPage.tsx b/src/pages/quotes/StatusQuotePage.tsx similarity index 82% rename from src/pages/quotes/PendingQuotesPage.tsx rename to src/pages/quotes/StatusQuotePage.tsx index 0fe7c49..2465c42 100644 --- a/src/pages/quotes/PendingQuotesPage.tsx +++ b/src/pages/quotes/StatusQuotePage.tsx @@ -6,7 +6,6 @@ import { Skeleton } from "@/components/ui/skeleton" import { InfoReply } from "@/generated/client" import { ListQuotesData } from "@/generated/client" import { adminLookupQuoteOptions, listQuotesOptions } 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" @@ -17,6 +16,12 @@ import { formatNumber, truncateString } from "@/utils/strings" import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" +type QuoteStatus = "Accepted" | "Denied" | "Expired" | "Offered" | "Pending" | "Rejected" | "Cancelled" + +interface StatusQuotePageProps { + status?: QuoteStatus +} + function Loader() { return (
@@ -91,15 +96,18 @@ function QuoteItemCard({ id, isLoading }: { id: InfoReply["id"]; isLoading: bool ) } -function QuoteListPending() { +function QuoteList({ status }: { status?: QuoteStatus }) { + const queryParams = status ? ({ status } as unknown as ListQuotesData["query"]) : {} + const { data, isFetching } = useSuspenseQuery({ ...listQuotesOptions({ - query: { - status: "Pending", - } as unknown as ListQuotesData["query"], + query: queryParams, }), }) + const statusText = status ? status.toLowerCase() : "all" + const noQuotesMessage = `No ${statusText} quotes.` + return ( <>
@@ -112,7 +120,7 @@ function QuoteListPending() {
- {data.quotes.length === 0 &&
No pending quotes.
} + {data.quotes.length === 0 &&
{noQuotesMessage}
} {data.quotes.map((it, index) => { return (
@@ -125,38 +133,20 @@ function QuoteListPending() { ) } -function DevSection() { - return <> - const [devMode] = useLocalStorage("devMode", false) - - const { data: quotesPending } = useSuspenseQuery({ - ...listQuotesOptions({}), - }) - - return ( - <> - {devMode && ( - <> -
-            {JSON.stringify(quotesPending, null, 2)}
-          
- - )} - - ) -} - -function PageBody() { +function PageBody({ status }: { status?: QuoteStatus }) { return (
- +
) } -export default function PendingQuotesPage() { +export default function StatusQuotePage({ status }: StatusQuotePageProps) { + const pageTitle = status ? `${status} Quotes` : "All Quotes" + const breadcrumbText = status ?? "All" + return ( <> , ]} > - Pending + {breadcrumbText} - Pending Quotes + {pageTitle} }> - - - - + ) diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 5fabd74..0901ebc 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -1,11 +1,7 @@ import { Breadcrumbs } from "@/components/Breadcrumbs" import { PageTitle } from "@/components/PageTitle" -import { Label } from "@/components/ui/label" 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 ( @@ -16,32 +12,9 @@ function Loader() { } function PageBody() { - const [devMode, setDevMode] = useLocalStorage("devMode", false) - return ( <> -
-
- { - toast.info( - <> - Developer mode is {(!devMode && "ON") || "OFF"} - , - { - id: "settings-dev-mode", - duration: 1_337, - }, - ) - setDevMode((it) => !it) - }} - /> - -
-
+
) } diff --git a/src/utils/dev.ts b/src/utils/dev.ts index 3f50e32..706f779 100644 --- a/src/utils/dev.ts +++ b/src/utils/dev.ts @@ -1,9 +1,31 @@ -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` +export function getDeterministicColor(seed?: string): string { + if (!seed) return "#64748b" // Default gray + + const colorPalette = [ + "#ef4444", + "#f97316", + "#f59e0b", + "#eab308", + "#84cc16", + "#22c55e", + "#10b981", + "#14b8a6", + "#06b6d4", + "#0ea5e9", + "#3b82f6", + "#6366f1", + "#8b5cf6", + "#a855f7", + "#d946ef", + "#ec4899", + "#f43f5e", + ] + + let hash = 0 + for (let i = 0; i < seed.length; i++) { + hash = ((hash << 5) - hash + seed.charCodeAt(i)) & 0xffffffff + } + + const colorIndex = Math.abs(hash) % colorPalette.length + return colorPalette[colorIndex] }