diff --git a/app/assets/star-cards/blockandbean.agi.cash.png b/app/assets/star-cards/blockandbean.agi.cash.png new file mode 100644 index 000000000..63d526e4c Binary files /dev/null and b/app/assets/star-cards/blockandbean.agi.cash.png differ diff --git a/app/assets/star-cards/compass.agi.cash.png b/app/assets/star-cards/compass.agi.cash.png new file mode 100644 index 000000000..8a60ef15b Binary files /dev/null and b/app/assets/star-cards/compass.agi.cash.png differ diff --git a/app/assets/star-cards/fake.agi.cash.png b/app/assets/star-cards/fake.agi.cash.png new file mode 100644 index 000000000..2db6feacd Binary files /dev/null and b/app/assets/star-cards/fake.agi.cash.png differ diff --git a/app/assets/star-cards/fake2.agi.cash.png b/app/assets/star-cards/fake2.agi.cash.png new file mode 100644 index 000000000..cd2fb314e Binary files /dev/null and b/app/assets/star-cards/fake2.agi.cash.png differ diff --git a/app/assets/star-cards/fake4.agi.cash.png b/app/assets/star-cards/fake4.agi.cash.png new file mode 100644 index 000000000..47242bfe2 Binary files /dev/null and b/app/assets/star-cards/fake4.agi.cash.png differ diff --git a/app/assets/star-cards/main-homepage-card.png b/app/assets/star-cards/main-homepage-card.png new file mode 100644 index 000000000..25e7fef31 Binary files /dev/null and b/app/assets/star-cards/main-homepage-card.png differ diff --git a/app/assets/star-cards/pinkowl.agi.cash.png b/app/assets/star-cards/pinkowl.agi.cash.png new file mode 100644 index 000000000..3fcceb225 Binary files /dev/null and b/app/assets/star-cards/pinkowl.agi.cash.png differ diff --git a/app/assets/star-cards/shack.agi.cash.png b/app/assets/star-cards/shack.agi.cash.png new file mode 100644 index 000000000..134fce316 Binary files /dev/null and b/app/assets/star-cards/shack.agi.cash.png differ diff --git a/app/assets/transparent-card.png b/app/assets/transparent-card.png new file mode 100644 index 000000000..56d367760 Binary files /dev/null and b/app/assets/transparent-card.png differ diff --git a/app/assets/whitelogo-small.png b/app/assets/whitelogo-small.png new file mode 100644 index 000000000..b86d8b1d0 Binary files /dev/null and b/app/assets/whitelogo-small.png differ diff --git a/app/components/money-display.tsx b/app/components/money-display.tsx index 25bc078c0..56ed031e9 100644 --- a/app/components/money-display.tsx +++ b/app/components/money-display.tsx @@ -20,7 +20,7 @@ const textVariants = cva('', { }, }); -const symbolVariants = cva('', { +const symbolVariants = cva('ml-[-0.025em] inline-block leading-none', { variants: { size: { sm: 'text-[1.33rem]', diff --git a/app/components/page.tsx b/app/components/page.tsx index a62cd0458..0e3f9bb4e 100644 --- a/app/components/page.tsx +++ b/app/components/page.tsx @@ -1,5 +1,6 @@ import { ChevronLeft, X } from 'lucide-react'; import React from 'react'; +import { useLocation } from 'react-router'; import { LinkWithViewTransition, type ViewTransitionLinkProps, @@ -14,7 +15,7 @@ export function Page({ children, className, ...props }: PageProps) { return (
+ ); @@ -83,7 +86,7 @@ export function PageHeader({ children, className, ...props }: PageHeaderProps) { return (
{/* Close/back button - always on the left */} @@ -129,7 +132,7 @@ export function PageContent({ return (
& { hideScrollbar?: boolean; + orientation?: 'vertical' | 'horizontal'; }; const ScrollArea = React.forwardRef< React.ElementRef, ScrollAreaProps ->(({ className, children, hideScrollbar = false, ...props }, ref) => ( - - - {children} - - - - -)); +>( + ( + { + className, + children, + hideScrollbar = false, + orientation = 'vertical', + ...props + }, + ref, + ) => ( + + + {children} + + + + + ), +); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< diff --git a/app/features/accounts/account-hooks.ts b/app/features/accounts/account-hooks.ts index 4f04d9101..5220574e6 100644 --- a/app/features/accounts/account-hooks.ts +++ b/app/features/accounts/account-hooks.ts @@ -7,6 +7,7 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useCallback, useMemo, useRef } from 'react'; +import { useSearchParams } from 'react-router'; import { type Currency, Money } from '~/lib/money'; import type { AgicashDbAccount } from '../agicash-db/database'; import { useUser } from '../user/user-hooks'; @@ -251,11 +252,14 @@ export function useAccounts(select?: { currency?: Currency; type?: T; isOnline?: boolean; + excludeStarAccounts?: boolean; + starAccountsOnly?: boolean; }): UseSuspenseQueryResult[]> { const user = useUser(); const accountRepository = useAccountRepository(); - const { currency, type, isOnline } = select ?? {}; + const { currency, type, isOnline, excludeStarAccounts, starAccountsOnly } = + select ?? {}; return useSuspenseQuery({ ...accountsQueryOptions({ userId: user.id, accountRepository }), @@ -280,13 +284,19 @@ export function useAccounts(select?: { if (isOnline !== undefined && account.isOnline !== isOnline) { return false; } + if (excludeStarAccounts && account.isStarAccount) { + return false; + } + if (starAccountsOnly && !account.isStarAccount) { + return false; + } return true; }, ); return filteredData; }, - [currency, type, isOnline, user], + [currency, type, isOnline, excludeStarAccounts, starAccountsOnly, user], ), }); } @@ -409,15 +419,18 @@ export function useAddCashuAccount() { return mutateAsync; } +/** + * @returns the total balance of all accounts for the given currency excluding Star accounts. + */ export function useBalance(currency: Currency) { - const { data: accounts } = useAccounts({ currency }); - const balance = accounts.reduce( - (acc, account) => { - const accountBalance = getAccountBalance(account); - return acc.add(accountBalance); - }, - new Money({ amount: 0, currency }), - ); + const { data: accounts } = useAccounts({ + currency, + excludeStarAccounts: true, + }); + const balance = accounts.reduce((acc, account) => { + const accountBalance = getAccountBalance(account); + return acc.add(accountBalance); + }, Money.zero(currency)); return balance; } @@ -437,3 +450,15 @@ export function useSelectItemsWithOnlineAccount() { [accountsCache], ); } + +/** + * Returns the account specified by the account ID in the URL. + * @param select - The type of the account to get. + */ +export function useGetAccountFromLocation(select?: { type?: AccountType }) { + const [searchParams] = useSearchParams(); + const accountId = searchParams.get('accountId'); + const { data: accounts } = useAccounts({ type: select?.type }); + const account = accounts.find((account) => account.id === accountId); + return account; +} diff --git a/app/features/accounts/account-icons.tsx b/app/features/accounts/account-icons.tsx index afd0c86f8..d4882df8b 100644 --- a/app/features/accounts/account-icons.tsx +++ b/app/features/accounts/account-icons.tsx @@ -1,15 +1,17 @@ -import { LandmarkIcon, Zap } from 'lucide-react'; +import { LandmarkIcon, StarIcon, Zap } from 'lucide-react'; import type { ReactNode } from 'react'; import type { AccountType } from './account'; const CashuIcon = () => ; const NWCIcon = () => ; +const StarsIcon = () => ; -const iconsByAccountType: Record = { +const iconsByAccountType: Record = { cashu: , nwc: , + star: , }; -export function AccountTypeIcon({ type }: { type: AccountType }) { +export function AccountTypeIcon({ type }: { type: AccountType | 'star' }) { return iconsByAccountType[type]; } diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index d921b44b8..f066ed7bb 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -7,7 +7,7 @@ import { import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import type { DistributedOmit } from 'type-fest'; import { - type MintInfo, + type ExtendedMintInfo, getCashuProtocolUnit, getCashuUnit, getCashuWallet, @@ -167,7 +167,7 @@ export class AccountRepository { proofs: string; }; - const { wallet, isOnline } = await this.getPreloadedWallet( + const { wallet, isOnline, isStarAccount } = await this.getPreloadedWallet( details.mint_url, data.currency, ); @@ -181,6 +181,7 @@ export class AccountRepository { keysetCounters: details.keyset_counters, proofs: await this.encryption.decrypt(details.proofs), wallet, + isStarAccount, } as T; } @@ -199,7 +200,7 @@ export class AccountRepository { private async getPreloadedWallet(mintUrl: string, currency: Currency) { const seed = await this.getCashuWalletSeed?.(); - let mintInfo: MintInfo; + let mintInfo: ExtendedMintInfo; let allMintKeysets: MintAllKeysets; let mintActiveKeys: MintActiveKeys; @@ -231,7 +232,7 @@ export class AccountRepository { unit: getCashuUnit(currency), bip39seed: seed ?? undefined, }); - return { wallet, isOnline: false }; + return { wallet, isOnline: false, isStarAccount: undefined }; } throw error; } @@ -266,7 +267,11 @@ export class AccountRepository { // The constructor does not set the keysetId, so we need to set it manually wallet.keysetId = activeKeyset.id; - return { wallet, isOnline: true }; + return { + wallet, + isOnline: true, + isStarAccount: mintInfo.internalMeltsOnly, + }; } } diff --git a/app/features/accounts/account-selector.tsx b/app/features/accounts/account-selector.tsx index 46727a142..702441969 100644 --- a/app/features/accounts/account-selector.tsx +++ b/app/features/accounts/account-selector.tsx @@ -45,7 +45,7 @@ function AccountItem({ account }: { account: AccountSelectorOption }) { return (
- +
{account.name}
diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index 7b833b55f..02ccfee70 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -9,6 +9,7 @@ export type Account = { name: string; type: AccountType; isOnline: boolean; + isStarAccount?: boolean; currency: Currency; createdAt: string; /** diff --git a/app/features/accounts/default-currency-switcher.tsx b/app/features/accounts/default-currency-switcher.tsx index dc4e357b3..cb794aab7 100644 --- a/app/features/accounts/default-currency-switcher.tsx +++ b/app/features/accounts/default-currency-switcher.tsx @@ -1,4 +1,3 @@ -import { ChevronDown } from 'lucide-react'; import { useState } from 'react'; import { Drawer, @@ -7,7 +6,6 @@ import { DrawerTitle, DrawerTrigger, } from '~/components/ui/drawer'; -import { RadioGroup, RadioGroupItem } from '~/components/ui/radio-group'; import { Separator } from '~/components/ui/separator'; import { useToast } from '~/hooks/use-toast'; import type { Currency } from '~/lib/money/types'; @@ -51,15 +49,21 @@ function CurrencyOption({ data, isSelected, onSelect }: CurrencyOptionProps) { {label}
- - - +
+ {isSelected && ( +
+
+
+ )} +
); } /** A drawer that allows the user to switch their default currency */ -export function DefaultCurrencySwitcher() { +export function DefaultCurrencySwitcher({ + children, +}: { children: React.ReactNode }) { const { toast } = useToast(); const [isOpen, setIsOpen] = useState(false); const defaultCurrency = useUser((user) => user.defaultCurrency); @@ -80,13 +84,11 @@ export function DefaultCurrencySwitcher() { return ( - - - - + {children} +
Select Currency diff --git a/app/features/discover/add-mint.tsx b/app/features/discover/add-mint.tsx new file mode 100644 index 000000000..57c402b3d --- /dev/null +++ b/app/features/discover/add-mint.tsx @@ -0,0 +1,84 @@ +import { useMutation } from '@tanstack/react-query'; +import { + PageBackButton, + PageContent, + PageFooter, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/hooks/use-toast'; +import type { Currency } from '~/lib/money'; +import { useNavigateWithViewTransition } from '~/lib/transitions'; +import { useAddCashuAccount } from '../accounts/account-hooks'; +import { getErrorMessage } from '../shared/error'; +import { WalletCard } from '../stars/wallet-card'; + +type Props = { + mintUrl: string; + currency: Currency; + name: string; +}; + +/** + * Component for adding a new mint to the user's wallet. + * Displays a preview of the mint card for the specified currency. + */ +export default function AddMint({ mintUrl, currency, name }: Props) { + const { toast } = useToast(); + const navigate = useNavigateWithViewTransition(); + + const addCashuAccount = useAddCashuAccount(); + + const { mutate: addMintMutation, status: addMintStatus } = useMutation({ + mutationFn: async () => { + const newAccount = await addCashuAccount({ + name, + type: 'cashu', + mintUrl, + currency, + }); + return newAccount; + }, + onSuccess: (account) => { + navigate(`/cards?accountId=${account.id}`, { + transition: 'fade', + applyTo: 'newView', + }); + }, + onError: (error) => { + console.error('Error adding mint', { cause: error }); + toast({ + title: 'Failed to add card', + description: getErrorMessage(error), + variant: 'destructive', + }); + }, + }); + + return ( + <> + + + Add Card + + +
+
+ +
+
+
+ + + + + + ); +} diff --git a/app/features/pwa/install-pwa-banner.tsx b/app/features/pwa/install-pwa-banner.tsx index 6ab27ad75..6ed847c9a 100644 --- a/app/features/pwa/install-pwa-banner.tsx +++ b/app/features/pwa/install-pwa-banner.tsx @@ -63,7 +63,7 @@ export default function InstallPwaBanner({
diff --git a/app/features/receive/receive-cashu-token-service.ts b/app/features/receive/receive-cashu-token-service.ts index 098866204..beb2f347c 100644 --- a/app/features/receive/receive-cashu-token-service.ts +++ b/app/features/receive/receive-cashu-token-service.ts @@ -199,7 +199,7 @@ export class ReceiveCashuTokenService { sourceAccount: CashuAccountWithTokenFlags, otherAccounts: CashuAccountWithTokenFlags[], ): CashuAccountWithTokenFlags[] { - if (sourceAccount.isTestMint) { + if (sourceAccount.isTestMint || sourceAccount.isStarAccount) { // Tokens sourced from test mint can only be claimed to the same mint return sourceAccount.canReceive ? [sourceAccount] : []; } diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 571116577..e24a13a7a 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -32,6 +32,7 @@ import { AccountSelector } from '../accounts/account-selector'; import { tokenToMoney } from '../shared/cashu'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; +import { WalletCard } from '../stars/wallet-card'; import { useAuthActions } from '../user/auth'; import { useFailCashuReceiveQuote } from './cashu-receive-quote-hooks'; import { useCreateCashuTokenSwap } from './cashu-token-swap-hooks'; @@ -204,14 +205,22 @@ export default function ReceiveToken({
{claimableToken && receiveAccount ? ( -
- -
+ receiveAccount.isStarAccount ? ( +
+ +
+ ) : ( +
+ +
+ ) ) : ( {claimableToken ? ( -
- -
+ sourceAccount.isStarAccount ? ( +
+ +
+ ) : ( +
+ +
+ ) ) : ( )} diff --git a/app/features/receive/receive-input.tsx b/app/features/receive/receive-input.tsx index ab47eb305..c72e9b57a 100644 --- a/app/features/receive/receive-input.tsx +++ b/app/features/receive/receive-input.tsx @@ -156,7 +156,7 @@ export default function ReceiveInput() { Receive - +
- + 0} onButtonClick={(value) => { diff --git a/app/features/send/send-input.tsx b/app/features/send/send-input.tsx index 279747f32..a39485bf5 100644 --- a/app/features/send/send-input.tsx +++ b/app/features/send/send-input.tsx @@ -200,7 +200,7 @@ export function SendInput() { Send - +
@@ -261,15 +261,20 @@ export function SendInput() { > - - + {/* TODO: maybe it would be better to put this property on the account object */} + {!sendAccount.isStarAccount && ( + + )}
{/* spacer */} -
+
@@ -277,7 +282,7 @@ export function SendInput() {
- + 0} onButtonClick={(value) => { diff --git a/app/features/send/share-cashu-token.tsx b/app/features/send/share-cashu-token.tsx index 90038c1f6..cab201dcd 100644 --- a/app/features/send/share-cashu-token.tsx +++ b/app/features/send/share-cashu-token.tsx @@ -104,7 +104,7 @@ export function ShareCashuToken({ token }: Props) {
{showOk && ( - + + + + + +
+
+
+ + {/* Transaction List Section */} + {/*
+ +
*/} +
+ ); +} diff --git a/app/features/stars/wallet-card.tsx b/app/features/stars/wallet-card.tsx new file mode 100644 index 000000000..5ed3adfa2 --- /dev/null +++ b/app/features/stars/wallet-card.tsx @@ -0,0 +1,263 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { MoneyDisplay } from '~/components/money-display'; +import { + type CashuAccount, + getAccountBalance, +} from '~/features/accounts/account'; +import { getDefaultUnit } from '~/features/shared/currencies'; +import { cn } from '~/lib/utils'; +import { mintInfoQueryOptions } from '../shared/cashu'; +import { + ANIMATION_DURATION, + CARD_STACK_OFFSET, + EASE_IN_OUT, + getOffScreenOffset, +} from './animation-constants'; +import { BaseCard } from './base-card'; + +/** + * Lazy import functions for card assets. + * These are cached by the browser's module system after first import. + */ +const cardAssetLoaders = import.meta.glob<{ default: string }>( + '../../assets/star-cards/*.png', +); + +/** + * Extracts the domain name from a file path. + * e.g., '../../assets/star-cards/fake.agi.cash.png' -> 'fake.agi.cash' + */ +function extractDomainFromPath(path: string): string { + return path.split('/').pop()?.replace('.png', '') || ''; +} + +/** + * Gets the domain from a mint URL + */ +function getDomainFromMintUrl(mintUrl: string): string { + return mintUrl.replace(/^https?:\/\//, ''); +} + +/** + * Loads a card asset dynamically if it exists. + */ +async function loadCardAsset(mintUrl: string): Promise { + const domain = getDomainFromMintUrl(mintUrl); + const loaderEntry = Object.entries(cardAssetLoaders).find( + ([path]) => extractDomainFromPath(path) === domain, + ); + if (!loaderEntry) { + return null; + } + try { + const [, loader] = loaderEntry; + const module = await loader(); + return module.default; + } catch (error) { + console.error(`Failed to load card asset for ${domain}:`, error); + return null; + } +} + +interface WalletCardProps { + account?: CashuAccount; + mintUrl?: string; + hideHeader?: boolean; + hideFooter?: boolean; + showBalanceOnly?: boolean; + className?: string; +} + +export function WalletCard({ + account, + mintUrl, + hideHeader = false, + hideFooter = false, + showBalanceOnly = false, + className, +}: WalletCardProps) { + const [customDesignPath, setCustomDesignPath] = useState(null); + + const effectiveMintUrl = account?.mintUrl ?? mintUrl; + if (!effectiveMintUrl) { + throw new Error('Mint URL is required'); + } + const { data: mintInfo } = useSuspenseQuery( + mintInfoQueryOptions(effectiveMintUrl), + ); + + useEffect(() => { + if (effectiveMintUrl) { + loadCardAsset(effectiveMintUrl).then(setCustomDesignPath); + } + }, [effectiveMintUrl]); + + const cardName = mintInfo.name; + const cardLogo = mintInfo.iconUrl ?? null; + const cardType = mintInfo.description ?? null; + + const balance = account ? getAccountBalance(account) : null; + + return ( + + {/* Centered logo and name - only shown when using fallback design */} + {!customDesignPath && account && ( +
+ {cardLogo ? ( + {`${cardName} + ) : ( +
+ {cardName.charAt(0)} +
+ )} +

{cardName}

+
+ )} + + {/* Default card content */} + {account && ( +
+ {/* Card Header with Logo, Vendor, and Balance */} +
+ {/* Logo */} +
+ {cardLogo ? ( + {`${cardName} + ) : ( +
+ {cardName.charAt(0)} +
+ )} +
+ + {/* Card Info */} +
+

{cardName}

+
+ + {/* Balance */} + {balance && ( +
+ +
+ )} +
+ + {/* Card Footer with Card Type */} + {cardType && ( +
+

{cardType}

+
+ )} +
+ )} +
+ ); +} + +interface SelectableWalletCardProps { + account: CashuAccount; + isSelected: boolean; + index: number; + onSelect: (accountId: string, event?: React.MouseEvent) => void; + selectedCardIndex: number | null; + hideHeader?: boolean; + hideFooter?: boolean; + showBalanceOnly?: boolean; + className?: string; +} + +/** + * A selectable wallet card component with animations. + * Wraps WalletCard with selection logic, click handlers, and animation behavior. + * Use this in contexts where cards need selection/animation behavior (e.g., card stack). + */ +export function SelectableWalletCard({ + account, + isSelected, + index, + onSelect, + selectedCardIndex, + hideHeader = false, + hideFooter = false, + showBalanceOnly = false, + className, +}: SelectableWalletCardProps) { + const handleCardClick = (e: React.MouseEvent) => { + onSelect(account.id, e); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(account.id); + } + }; + + // Calculate Y position based on state + let yOffset: number; + if (isSelected) { + // This card is selected - move to top position + yOffset = 0; + } else if (selectedCardIndex !== null) { + // Another card is selected - slide this card off screen + yOffset = getOffScreenOffset(); + } else { + // No selection - normal stacked position + yOffset = index * CARD_STACK_OFFSET; + } + + // Fixed z-index based on position + const zIndex = 100 + index; + + const transition = `all ${ANIMATION_DURATION}ms ${EASE_IN_OUT}`; + + return ( + // biome-ignore lint/a11y/useSemanticElements: div needed for absolute positioning wrapper +
+ +
+ ); +} diff --git a/app/features/transactions/transaction-hooks.ts b/app/features/transactions/transaction-hooks.ts index c0da5fd83..8f4b919fe 100644 --- a/app/features/transactions/transaction-hooks.ts +++ b/app/features/transactions/transaction-hooks.ts @@ -131,18 +131,19 @@ export function useSuspenseTransaction(id: string) { const PAGE_SIZE = 25; -export function useTransactions() { +export function useTransactions(select?: { accountId?: string }) { const userId = useUser((user) => user.id); const transactionRepository = useTransactionRepository(); const result = useInfiniteQuery({ - queryKey: [allTransactionsQueryKey], + queryKey: [allTransactionsQueryKey, select], initialPageParam: null, queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => { const result = await transactionRepository.list({ userId, cursor: pageParam, pageSize: PAGE_SIZE, + select, }); return { transactions: result.transactions, diff --git a/app/features/transactions/transaction-list.tsx b/app/features/transactions/transaction-list.tsx index 118572286..c3d046537 100644 --- a/app/features/transactions/transaction-list.tsx +++ b/app/features/transactions/transaction-list.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, } from 'react'; +import { useLocation } from 'react-router'; import { Card } from '~/components/ui/card'; import { ScrollArea } from '~/components/ui/scroll-area'; import { useTransactionAckStatusStore } from '~/features/transactions/transaction-ack-status-store'; @@ -151,6 +152,7 @@ function TransactionRow({ transaction: Transaction; }) { const { mutate: acknowledgeTransaction } = useAcknowledgeTransaction(); + const location = useLocation(); const { setAckStatus, statuses: ackStatuses } = useTransactionAckStatusStore(); @@ -168,7 +170,7 @@ function TransactionRow({ return ( data?.pages.flatMap((page) => page.transactions) ?? [], diff --git a/app/features/transactions/transaction-repository.ts b/app/features/transactions/transaction-repository.ts index 401a7a7ae..618600fa5 100644 --- a/app/features/transactions/transaction-repository.ts +++ b/app/features/transactions/transaction-repository.ts @@ -33,6 +33,10 @@ type ListOptions = Options & { userId: string; cursor?: Cursor; pageSize?: number; + /** + * Optional filters to apply to the transaction list. + bun */ + select?: { accountId?: string }; }; type UnifiedTransactionDetails = @@ -68,6 +72,7 @@ export class TransactionRepository { userId, cursor = null, pageSize = 25, + select, abortSignal, }: ListOptions) { const query = this.db.rpc('list_transactions', { @@ -76,6 +81,7 @@ export class TransactionRepository { p_cursor_created_at: cursor?.createdAt, p_cursor_id: cursor?.id, p_page_size: pageSize, + p_account_id: select?.accountId, }); if (abortSignal) { diff --git a/app/features/user/user-hooks.tsx b/app/features/user/user-hooks.tsx index 6aaf21d49..1344e3c20 100644 --- a/app/features/user/user-hooks.tsx +++ b/app/features/user/user-hooks.tsx @@ -95,29 +95,22 @@ export const defaultAccounts = [ { type: 'cashu', currency: 'BTC', - name: 'Testnut BTC (nofees)', - mintUrl: 'https://nofees.testnut.cashu.space', - isTestMint: true, - }, - { - type: 'cashu', - currency: 'USD', - name: 'Testnut USD (nofees)', - mintUrl: 'https://nofees.testnut.cashu.space', + name: 'CafeRX', + mintUrl: 'https://fake3.agi.cash', isTestMint: true, }, { type: 'cashu', currency: 'BTC', - name: 'Testnut BTC', - mintUrl: 'https://testnut.cashu.space', + name: 'NYTimes', + mintUrl: 'https://fake2.agi.cash', isTestMint: true, }, { type: 'cashu', - currency: 'USD', - name: 'Testnut USD', - mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + name: 'Maple', + mintUrl: 'https://fake4.agi.cash', isTestMint: true, }, ] as const) diff --git a/app/hooks/use-is-desktop.ts b/app/hooks/use-is-desktop.ts new file mode 100644 index 000000000..073c1172b --- /dev/null +++ b/app/hooks/use-is-desktop.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +/** + * Hook to detect if the viewport is desktop size (matching Tailwind's md breakpoint: >= 768px) + * Returns true if the viewport is desktop size, false if mobile/tablet + */ +export function useIsDesktop() { + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + // Tailwind's md breakpoint is 768px + const mediaQuery = window.matchMedia('(min-width: 768px)'); + + // Set initial value + setIsDesktop(mediaQuery.matches); + + // Listen for changes + const handleChange = (e: MediaQueryListEvent) => { + setIsDesktop(e.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return isDesktop; +} diff --git a/app/lib/cashu/extended-mint-info.ts b/app/lib/cashu/extended-mint-info.ts new file mode 100644 index 000000000..5096fe6c2 --- /dev/null +++ b/app/lib/cashu/extended-mint-info.ts @@ -0,0 +1,178 @@ +/** + * This class was copied from cashu-ts v2.7.2 and extended with the following methods: + * - get iconUrl + * - get internalMeltsOnly + * + * As of cashu-ts v2.7.2, the MintInfo class is not exported, so we need to copy it here in order to extend it. + */ + +import type { + GetInfoResponse, + MPPMethod, + SwapMethod, + WebSocketSupport, +} from '@cashu/cashu-ts'; + +/** + * A class that represents the data fetched from the mint's + * [NUT-06 info endpoint](https://github.com/cashubtc/nuts/blob/main/06.md) + */ +export class ExtendedMintInfo { + private readonly _mintInfo: GetInfoResponse; + private readonly _protectedEnpoints?: { + cache: { + [url: string]: boolean; + }; + apiReturn: Array<{ + method: 'GET' | 'POST'; + regex: RegExp; + cachedValue?: boolean; + }>; + }; + + constructor(info: GetInfoResponse) { + this._mintInfo = info; + if (info.nuts[22]) { + this._protectedEnpoints = { + cache: {}, + apiReturn: info.nuts[22].protected_endpoints.map((o) => ({ + method: o.method, + regex: new RegExp(o.path), + })), + }; + } + } + + isSupported(num: 4 | 5): { disabled: boolean; params: SwapMethod[] }; + isSupported(num: 7 | 8 | 9 | 10 | 11 | 12 | 14 | 20): { supported: boolean }; + isSupported(num: 17): { supported: boolean; params?: WebSocketSupport[] }; + isSupported(num: 15): { supported: boolean; params?: MPPMethod[] }; + isSupported(num: number) { + switch (num) { + case 4: + case 5: { + return this.checkMintMelt(num); + } + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 14: + case 20: { + return this.checkGenericNut(num); + } + case 17: { + return this.checkNut17(); + } + case 15: { + return this.checkNut15(); + } + default: { + throw new Error('nut is not supported by cashu-ts'); + } + } + } + + requiresBlindAuthToken(path: string) { + if (!this._protectedEnpoints) { + return false; + } + if (typeof this._protectedEnpoints.cache[path] === 'boolean') { + return this._protectedEnpoints.cache[path]; + } + const isProtectedEndpoint = this._protectedEnpoints.apiReturn.some((e) => + e.regex.test(path), + ); + this._protectedEnpoints.cache[path] = isProtectedEndpoint; + return isProtectedEndpoint; + } + + private checkGenericNut(num: 7 | 8 | 9 | 10 | 11 | 12 | 14 | 20) { + if (this._mintInfo.nuts[num]?.supported) { + return { supported: true }; + } + return { supported: false }; + } + private checkMintMelt(num: 4 | 5) { + const mintMeltInfo = this._mintInfo.nuts[num]; + if ( + mintMeltInfo && + mintMeltInfo.methods.length > 0 && + !mintMeltInfo.disabled + ) { + return { disabled: false, params: mintMeltInfo.methods }; + } + return { disabled: true, params: mintMeltInfo.methods }; + } + private checkNut17() { + if ( + this._mintInfo.nuts[17] && + this._mintInfo.nuts[17].supported.length > 0 + ) { + return { supported: true, params: this._mintInfo.nuts[17].supported }; + } + return { supported: false }; + } + private checkNut15() { + if (this._mintInfo.nuts[15] && this._mintInfo.nuts[15].methods.length > 0) { + return { supported: true, params: this._mintInfo.nuts[15].methods }; + } + return { supported: false }; + } + + get contact() { + return this._mintInfo.contact; + } + + get description() { + return this._mintInfo.description; + } + + get description_long() { + return this._mintInfo.description_long; + } + + get name() { + return this._mintInfo.name; + } + + get pubkey() { + return this._mintInfo.pubkey; + } + + get nuts() { + return this._mintInfo.nuts; + } + + get version() { + return this._mintInfo.version; + } + + get motd() { + return this._mintInfo.motd; + } + + // Below methods are added in addition to what the cashu-ts MintInfo class provides + + get iconUrl() { + return this._mintInfo.icon_url; + } + + /** + * Whether the mint only allows internal melts. + * + * NOTE: This flag is not currently defined in the NUTs. + * Internal melts only is a feature that we have added to agicash mints + * for creating a closed-loop mint. + */ + get internalMeltsOnly() { + const methods = this._mintInfo.nuts[5].methods as (SwapMethod & { + options?: { internal_melts_only?: boolean }; + })[]; + return methods.some( + (method) => method.options?.internal_melts_only === true, + ); + } +} diff --git a/app/lib/cashu/index.ts b/app/lib/cashu/index.ts index f71b5caea..67792be60 100644 --- a/app/lib/cashu/index.ts +++ b/app/lib/cashu/index.ts @@ -3,5 +3,5 @@ export * from './secret'; export * from './token'; export * from './utils'; export * from './error-codes'; -export type { MintInfo } from './types'; export * from './payment-request'; +export * from './extended-mint-info'; diff --git a/app/lib/cashu/mint-validation.ts b/app/lib/cashu/mint-validation.ts index 125392f40..246a9ae19 100644 --- a/app/lib/cashu/mint-validation.ts +++ b/app/lib/cashu/mint-validation.ts @@ -1,10 +1,6 @@ import type { MintKeyset, WebSocketSupport } from '@cashu/cashu-ts'; -import type { - CashuProtocolUnit, - MintInfo, - NUT, - NUT17WebSocketCommand, -} from './types'; +import type { ExtendedMintInfo } from './extended-mint-info'; +import type { CashuProtocolUnit, NUT, NUT17WebSocketCommand } from './types'; type NutValidationResult = | { isValid: false; message: string } @@ -12,7 +8,7 @@ type NutValidationResult = type NutValidation = { nut: NUT; - validate: (info: MintInfo, unit: string) => NutValidationResult; + validate: (info: ExtendedMintInfo, unit: string) => NutValidationResult; }; type BuildMintValidatorOptions = { @@ -36,7 +32,7 @@ export const buildMintValidator = (params: BuildMintValidatorOptions) => { return ( mintUrl: string, selectedUnit: CashuProtocolUnit, - mintInfo: MintInfo, + mintInfo: ExtendedMintInfo, keysets: MintKeyset[], ): string | true => { if (!/^https?:\/\/.+/.test(mintUrl)) { @@ -155,7 +151,7 @@ const createNutValidators = ({ }; const validateBolt11Support = ( - info: MintInfo, + info: ExtendedMintInfo, operation: 'minting' | 'melting', unit: string, ): NutValidationResult => { @@ -184,7 +180,7 @@ const validateBolt11Support = ( }; const validateGenericNut = ( - info: MintInfo, + info: ExtendedMintInfo, nut: Extract, message: string, ): NutValidationResult => { @@ -199,7 +195,7 @@ const validateGenericNut = ( }; const validateWebSocketSupport = ( - info: MintInfo, + info: ExtendedMintInfo, unit: string, requiredCommands: NUT17WebSocketCommand[], ): NutValidationResult => { @@ -229,7 +225,7 @@ const validateWebSocketSupport = ( }; const validateMintFeatures = ( - mintInfo: MintInfo, + mintInfo: ExtendedMintInfo, unit: string, nutValidators: NutValidation[], ): NutValidationResult => { diff --git a/app/lib/cashu/types.ts b/app/lib/cashu/types.ts index 5ee7ffea0..b6902bb58 100644 --- a/app/lib/cashu/types.ts +++ b/app/lib/cashu/types.ts @@ -1,4 +1,3 @@ -import type { CashuWallet } from '@cashu/cashu-ts'; import { z } from 'zod'; /** @@ -140,12 +139,6 @@ export type ProofSecret = */ export type P2PKSecret = NUT10Secret & { kind: 'P2PK' }; -/** - * A class that represents the data fetched from the mint's - * [NUT-06 info endpoint](https://github.com/cashubtc/nuts/blob/main/06.md) - */ -export type MintInfo = Awaited>; - /** * The units that are determined by the soft-consensus of cashu mints and wallets. * These units are not definite as they are not defined in NUTs directly. diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index 31ce75db2..6cddbba7a 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -9,6 +9,7 @@ import Big from 'big.js'; import type { DistributedOmit } from 'type-fest'; import { decodeBolt11 } from '~/lib/bolt11'; import type { Currency, CurrencyUnit } from '../money'; +import { ExtendedMintInfo } from './extended-mint-info'; import { sumProofs } from './proof'; import type { CashuProtocolUnit } from './types'; @@ -83,13 +84,17 @@ export const getWalletCurrency = (wallet: CashuWallet) => { */ export class ExtendedCashuWallet extends CashuWallet { private _bip39Seed: Uint8Array | undefined; + private _extendedMintInfo: ExtendedMintInfo | undefined; constructor( mint: CashuMint, - options: ConstructorParameters[1], + options?: ConstructorParameters[1] & { + mintInfo?: ExtendedMintInfo; + }, ) { super(mint, options); this._bip39Seed = options?.bip39seed; + this._extendedMintInfo = options?.mintInfo; } get seed() { @@ -156,6 +161,15 @@ export class ExtendedCashuWallet extends CashuWallet { return fee; } + async getExtendedMintInfo() { + if (this._extendedMintInfo) { + return this._extendedMintInfo; + } + const info = new ExtendedMintInfo(await this.mint.getInfo()); + this._extendedMintInfo = info; + return info; + } + private getMinNumberOfProofsForAmount(keys: Keys, amount: Big) { const availableDenominations = Object.keys(keys).map((x) => new Big(x)); const biggestDenomination = availableDenominations.reduce( @@ -205,6 +219,7 @@ export const getCashuWallet = ( 'unit' > & { unit?: CurrencyUnit; + mintInfo?: ExtendedMintInfo; } = {}, ) => { const { unit, ...rest } = options; diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index e1a508993..a539cb05d 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -1,74 +1,137 @@ -import { - ArrowDownRight, - ArrowUpRight, - ChartSpline, - Clock, - Cog, -} from 'lucide-react'; -import { useState } from 'react'; +import { Clock, Star, UserCircle2 } from 'lucide-react'; +import { useMemo } from 'react'; import type { LinksFunction } from 'react-router'; import agicashIcon192 from '~/assets/icon-192x192.png'; -import { Page, PageContent, PageHeader } from '~/components/page'; +import blockandBeanCard from '~/assets/star-cards/blockandbean.agi.cash.png'; +import compassCoffeeCard from '~/assets/star-cards/compass.agi.cash.png'; +import fakeCard from '~/assets/star-cards/fake.agi.cash.png'; +import fake2Card from '~/assets/star-cards/fake2.agi.cash.png'; +import fake4Card from '~/assets/star-cards/fake4.agi.cash.png'; +import pinkOwlCoffeeCard from '~/assets/star-cards/pinkowl.agi.cash.png'; +import theShackCard from '~/assets/star-cards/shack.agi.cash.png'; +import transparentCardBg from '~/assets/transparent-card.png'; +import whiteLogoSmall from '~/assets/whitelogo-small.png'; +import { Page, PageContent, PageFooter } from '~/components/page'; import { Button } from '~/components/ui/button'; -import { Skeleton } from '~/components/ui/skeleton'; +import { Card } from '~/components/ui/card'; +import { ScrollArea } from '~/components/ui/scroll-area'; +import { getAccountBalance } from '~/features/accounts/account'; import { - useBalance, + useAccounts, useDefaultAccount, } from '~/features/accounts/account-hooks'; import { DefaultCurrencySwitcher } from '~/features/accounts/default-currency-switcher'; import { InstallPwaPrompt } from '~/features/pwa/install-pwa-prompt'; import { MoneyWithConvertedAmount } from '~/features/shared/money-with-converted-amount'; +import { CurrencyCard } from '~/features/stars/currency-card'; +import { WalletCard } from '~/features/stars/wallet-card'; import { useHasTransactionsPendingAck } from '~/features/transactions/transaction-hooks'; -import { useExchangeRates } from '~/hooks/use-exchange-rate'; -import type { Ticker } from '~/lib/exchange-rate'; +import { useIsDesktop } from '~/hooks/use-is-desktop'; import { Money } from '~/lib/money'; import { LinkWithViewTransition } from '~/lib/transitions'; export const links: LinksFunction = () => [ // This icon is used in the PWA dialog and prefetched here to avoid a flash while loading { rel: 'preload', href: agicashIcon192, as: 'image' }, + { rel: 'preload', href: transparentCardBg, as: 'image' }, + { rel: 'preload', href: whiteLogoSmall, as: 'image' }, + { rel: 'preload', href: compassCoffeeCard, as: 'image' }, + { rel: 'preload', href: pinkOwlCoffeeCard, as: 'image' }, + { rel: 'preload', href: theShackCard, as: 'image' }, + { rel: 'preload', href: fakeCard, as: 'image' }, + { rel: 'preload', href: fake2Card, as: 'image' }, + { rel: 'preload', href: fake4Card, as: 'image' }, + { rel: 'preload', href: blockandBeanCard, as: 'image' }, ]; -const Price = () => { - const [showSatsPerDollar, setShowSatsPerDollar] = useState(false); - const { data: rates } = useExchangeRates( - (['BTC-USD', 'USD-BTC'] as Ticker[]).sort(), - ); - - if (!rates) return ; - - const moneyString = showSatsPerDollar - ? new Money({ amount: 1, currency: 'USD' }) - .convert('BTC', rates['USD-BTC']) - .toLocaleString({ unit: 'sat' }) - : new Money({ amount: rates['BTC-USD'], currency: 'USD' }) - .toLocaleString({ unit: 'usd' }) - .slice(0, -3); - - return ( - - ); +type DiscoverMint = { + url: string; + name: string; + image: string; + currency: 'BTC' | 'USD'; }; +const DISCOVER_MINTS: DiscoverMint[] = [ + { + url: 'https://blockandbean.agi.cash', + name: 'Block and Bean', + image: blockandBeanCard, + currency: 'BTC', + }, + { + url: 'https://fake.agi.cash', + name: 'Pubkey', + image: fakeCard, + currency: 'BTC', + }, + // { + // url: 'https://fake2.agi.cash', + // name: 'NYTimes', + // image: fake2Card, + // currency: 'BTC', + // }, + { + url: 'https://fake4.agi.cash', + name: 'Maple', + image: fake4Card, + currency: 'BTC', + }, + { + url: 'https://compass.agi.cash', + name: 'Compass Coffee', + image: compassCoffeeCard, + currency: 'BTC', + }, + { + url: 'https://pinkowl.agi.cash', + name: 'Pink Owl Coffee', + image: pinkOwlCoffeeCard, + currency: 'BTC', + }, + { + url: 'https://shack.agi.cash', + name: 'The Shack', + image: theShackCard, + currency: 'BTC', + }, +]; + export default function Index() { - const balanceBTC = useBalance('BTC'); - const balanceUSD = useBalance('USD'); - const defaultCurrency = useDefaultAccount().currency; const hasTransactionsPendingAck = useHasTransactionsPendingAck(); + const isDesktop = useIsDesktop(); + + const { data: starAccounts } = useAccounts({ + type: 'cashu', + starAccountsOnly: true, + }); + + const sortedStarAccounts = useMemo(() => { + return [...starAccounts].sort((a, b) => { + const balanceA = getAccountBalance(a); + const balanceB = getAccountBalance(b); + return Money.compare(balanceB, balanceA); + }); + }, [starAccounts]); + + const discoverMints = useMemo(() => { + const existingMintUrls = new Set( + starAccounts.map((account) => account.mintUrl), + ); + return DISCOVER_MINTS.filter((mint) => !existingMintUrls.has(mint.url)); + }, [starAccounts]); return ( - - + + {/* Fixed header layer - positioned above scrolling content */} +
+ + + +
- +
- - - -
- - {defaultCurrency === 'BTC' && } +
+ + {/* Fade gradient overlay - top (creates fade effect for scrolling content) */} +
+ + +
+
- + {sortedStarAccounts.length > 0 && ( +
+

For You

+
+ +
+ {sortedStarAccounts.map((account) => ( + + + + + + + ))} +
+
+
+
+ )} -
+ {discoverMints.length > 0 && ( +
+

Discover

+
+ +
+ {discoverMints.map((mint) => ( + + + {mint.name} + + + ))} +
+
+
+
+ )} + + + + {/* Fade gradient overlay - bottom (creates fade effect for scrolling content) */} +
+
- + - +
- + ); } + +export function HomePageCard() { + const defaultAccount = useDefaultAccount(); + + return ( + + + + ); +} diff --git a/app/routes/_protected.cards._index.tsx b/app/routes/_protected.cards._index.tsx new file mode 100644 index 000000000..2ad8c7167 --- /dev/null +++ b/app/routes/_protected.cards._index.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { + ClosePageButton, + Page, + PageBackButton, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import { useAccounts } from '~/features/accounts/account-hooks'; +import { CardStack } from '~/features/stars/card-stack'; +import { SelectedCardDetails } from '~/features/stars/selected-card-details'; + +export default function Cards() { + const [searchParams, setSearchParams] = useSearchParams(); + const accountIdFromUrl = searchParams.get('accountId'); + + const { data: starAccounts } = useAccounts({ + type: 'cashu', + starAccountsOnly: true, + }); + + const initialSelectedId = + accountIdFromUrl || (starAccounts.length === 1 ? starAccounts[0].id : null); + + const [selectedAccountId, setSelectedAccountId] = useState( + initialSelectedId, + ); + + // Handle accountId from URL + useEffect(() => { + if (accountIdFromUrl) { + setSelectedAccountId(accountIdFromUrl); + // Clear the accountId from URL after selecting + setSearchParams({}, { replace: true }); + } + }, [accountIdFromUrl, setSearchParams]); + const selectedCardIndex = starAccounts.findIndex( + (acc) => acc.id === selectedAccountId, + ); + const selectedAccount = + selectedCardIndex >= 0 ? starAccounts[selectedCardIndex] : null; + + const handleCardSelect = (accountId: string, event?: React.MouseEvent) => { + event?.stopPropagation(); + + // If only one card, keep it selected + if (starAccounts.length === 1) { + return; + } + + // Toggle selection: if already selected, deselect it + setSelectedAccountId((prev) => (prev === accountId ? null : accountId)); + }; + + const handleBackButtonClick = (event: React.MouseEvent) => { + // If only one card, don't prevent back navigation + if (starAccounts.length === 1) { + return; + } + + // If a card is selected, deselect it instead of navigating + if (selectedAccount) { + event.preventDefault(); + setSelectedAccountId(null); + } + }; + + return ( + + + {selectedAccount && starAccounts.length > 1 ? ( + + ) : ( + + )} + + Loyalty + + + +
+ {/* Cards Stack */} +
+ = 0 ? selectedCardIndex : null + } + onCardSelect={handleCardSelect} + /> +
+ + {/* Selected Card Details: Send/Receive buttons and transaction list */} + {selectedAccount && ( + + )} +
+
+
+ ); +} diff --git a/app/routes/_protected.cards.tsx b/app/routes/_protected.cards.tsx new file mode 100644 index 000000000..8fbb343aa --- /dev/null +++ b/app/routes/_protected.cards.tsx @@ -0,0 +1,9 @@ +import { useState } from 'react'; +import { Outlet } from 'react-router'; +import { createTransactionAckStatusStore } from '~/features/transactions/transaction-ack-status-store'; + +export default function CardsLayout() { + const [store] = useState(() => createTransactionAckStatusStore()); + + return ; +} diff --git a/app/routes/_protected.discover.add-mint.tsx b/app/routes/_protected.discover.add-mint.tsx new file mode 100644 index 000000000..7327a6ae1 --- /dev/null +++ b/app/routes/_protected.discover.add-mint.tsx @@ -0,0 +1,35 @@ +import { useSearchParams } from 'react-router'; +import { Page } from '~/components/page'; +import AddMint from '~/features/discover/add-mint'; +import type { Currency } from '~/lib/money'; + +export default function AddMintRoute() { + const [searchParams] = useSearchParams(); + const mintUrl = searchParams.get('url'); + const currency = searchParams.get('currency') as Currency; + const name = searchParams.get('name'); + + if (!mintUrl || !currency || !name) { + return ( + +
+

+ {!mintUrl + ? 'Missing mint URL' + : !currency + ? 'Missing currency' + : !name + ? 'Missing name' + : ''} +

+
+
+ ); + } + + return ( + + + + ); +} diff --git a/app/routes/_protected.receive.tsx b/app/routes/_protected.receive.tsx index 30b2343d9..e5cf9d64e 100644 --- a/app/routes/_protected.receive.tsx +++ b/app/routes/_protected.receive.tsx @@ -1,12 +1,16 @@ import { Outlet } from 'react-router'; -import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { + useDefaultAccount, + useGetAccountFromLocation, +} from '~/features/accounts/account-hooks'; import { ReceiveProvider } from '~/features/receive'; export default function ReceiveLayout() { const defaultAccount = useDefaultAccount(); + const specifiedAccount = useGetAccountFromLocation({ type: 'cashu' }); return ( - + ); diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index d0c44418d..dbc41ba1b 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,12 +1,16 @@ import { Outlet } from 'react-router'; -import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { + useDefaultAccount, + useGetAccountFromLocation, +} from '~/features/accounts/account-hooks'; import { SendProvider } from '~/features/send'; export default function SendLayout() { const defaultAccount = useDefaultAccount(); + const specifiedAccount = useGetAccountFromLocation({ type: 'cashu' }); return ( - + ); diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 175ce4a61..13ea869d0 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -975,6 +975,7 @@ export type Database = { } list_transactions: { Args: { + p_account_id?: string p_cursor_created_at?: string p_cursor_id?: string p_cursor_state_sort_order?: number diff --git a/supabase/migrations/20251007204919_list-transactions-by-accountId.sql b/supabase/migrations/20251007204919_list-transactions-by-accountId.sql new file mode 100644 index 000000000..81cf4fb65 --- /dev/null +++ b/supabase/migrations/20251007204919_list-transactions-by-accountId.sql @@ -0,0 +1,72 @@ +-- Description: +-- This migration adds optional filtering by account_id to the list_transactions function. +-- +-- Affected: wallet.list_transactions function +-- +-- Changes: +-- - Adds optional p_account_id parameter to filter transactions by account +-- - Maintains backward compatibility (parameter is optional) +-- - Preserves existing pagination and sorting behavior +-- ======================================== + +-- Add composite index for account_id filtering +-- This index optimizes queries that filter by both user_id and account_id +-- It covers the full query pattern: filter by user_id + account_id, then sort by state_sort_order, created_at, id +create index if not exists idx_user_account_filtered_state_ordered +on wallet.transactions ( + user_id, + account_id, + state_sort_order desc, + created_at desc, + id desc +) +where state in ('PENDING', 'COMPLETED', 'REVERSED'); + +-- Drop the existing function to recreate it with the new signature +drop function if exists wallet.list_transactions(uuid, integer, timestamptz, uuid, integer); + +-- Recreate function with optional account_id filter parameter +create or replace function wallet.list_transactions( + p_user_id uuid, + p_cursor_state_sort_order integer default null, + p_cursor_created_at timestamptz default null, + p_cursor_id uuid default null, + p_page_size integer default 25, + p_account_id uuid default null -- New optional filter parameter +) +returns setof wallet.transactions +language plpgsql +stable +security definer +set search_path = '' +as $$ +begin + -- Check if cursor data is provided + if p_cursor_created_at is null then + -- Initial page load (no cursor) + return query + select t.* + from wallet.transactions t + where t.user_id = p_user_id + and t.state in ('PENDING', 'COMPLETED', 'REVERSED') + and (p_account_id is null or t.account_id = p_account_id) -- Apply account filter if provided + order by t.state_sort_order desc, t.created_at desc, t.id desc + limit p_page_size; + else + -- Subsequent pages (with cursor) + return query + select t.* + from wallet.transactions t + where t.user_id = p_user_id + and t.state in ('PENDING', 'COMPLETED', 'REVERSED') + and (p_account_id is null or t.account_id = p_account_id) -- Apply account filter if provided + and (t.state_sort_order, t.created_at, t.id) < ( + p_cursor_state_sort_order, + p_cursor_created_at, + p_cursor_id + ) + order by t.state_sort_order desc, t.created_at desc, t.id desc + limit p_page_size; + end if; +end; +$$;