diff --git a/.env.example b/.env.example index 80f9cce85..e69088d9e 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,5 @@ LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be3 VITE_CASHU_MINT_BLOCKLIST='[{"mintUrl":"https://mint.lnvoltz.com","unit":"usd"}]' # Feature Flags -VITE_FF_GUEST_SIGNUP=true \ No newline at end of file +VITE_FF_GUEST_SIGNUP=true +VITE_FF_GIFT_CARDS=true \ No newline at end of file diff --git a/.env.test b/.env.test index c7674ec03..4e74ab2b9 100644 --- a/.env.test +++ b/.env.test @@ -16,4 +16,5 @@ LNURL_SERVER_ENCRYPTION_KEY="a8861cc3e5b3caf5573fbba2da2851a4e608a836eef10074be3 VITE_CASHU_MINT_BLOCKLIST='[{"mintUrl":"https://mint.lnvoltz.com","unit":"usd"}]' # Feature Flags -VITE_FF_GUEST_SIGNUP=true \ No newline at end of file +VITE_FF_GUEST_SIGNUP=true +VITE_FF_GIFT_CARDS=true \ No newline at end of file diff --git a/README.md b/README.md index d7c3e1b4d..d47785a72 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,11 @@ bun run supabase start bun run dev ``` +Note that when running locally the app is still using hosted Open Secret environment which is dedicated for the local development, while +our hosted envs have their own dedicated Open Secret environments. This means that even while working on your machine you still need +internet connection for identity/auth, key management, etc. Configurations for the local Open Secret environment can be seen in +[Agicash local](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects/77f98196-3e2f-43ad-83d1-decdac01b0fa) project. + When testing the app on an actual mobile device, you need to connect to the same Wi-Fi as the machine hosting the app and access it via local IP or hostname. Unlike localhost or 127.0.0.1, those are not considered a safe context by the browser, so the browser APIs that require a safe context won't work. To solve this issue, you need to run the app on HTTPS @@ -49,12 +54,10 @@ A self-signed certificate is used for HTTPS. The certificate is managed by deven regenerate the certificate (for example, if your local IP has changed), reload devenv by executing `direnv reload` or run the certificate script directly by executing `generate-ssl-cert`. -**Installing the root certificate on mobile:** Mobile browsers may require the root CA to be installed and trusted on the -device. On **iOS**: - -1. AirDrop or email the `certs/rootCA.pem` file to your device and open it -2. Go to **Settings → General → VPN & Device Management** and install the downloaded profile -3. Go to **Settings → General → About → Certificate Trust Settings** and enable full trust for the root certificate +**Installing the root certificate on iOS:** Mobile browsers require the root CA to be trusted. Find your mkcert root CA +by running `mkcert -CAROOT` (typically `~/Library/Application Support/mkcert/rootCA.pem`), then AirDrop or email it to +your device. Install via **Settings → General → VPN & Device Management**, then enable trust in **Settings → General → +About → Certificate Trust Settings**. `master` is the main branch. When working on a feature, branch off `master` and, when ready, make a PR back to `master`. Try to make feature branches short-lived and concise (avoid implementing multiple features in one PR). @@ -101,6 +104,27 @@ branch/environment is automatically deleted once the feature branch is merged. To release a new `alpha` version, make a pull request from `master` to the `alpha` branch. +`alpha` environment has dedicated [Agicash Alpha](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects/ae22a864-b2f7-4e7e-bef0-2d194e0d20b6) +Open Secret environment. +`next` environment has dedicated [Agicash Next](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects/343cef25-2328-43b8-abc0-68a433bfc40e) +Open Secret environment. + +All preview deployments of the Agicash app are also using Agicash Next Open Secret environment. However, since Supabase envs for preview +deployments are created on demand and currently there is no way to configure static JWT secret for them, if you want the preview deployment to work with Open Secret +you need to copy the shared secret value used by Agicash Next Open Secret env and create the a new JWT signing key in the corresponding Supabase env. To do that: +1. Find the current value of the secret used by Agicash Next Open Secret env (ask other devs to share it) +2. Go to [Supabase dashboard](https://supabase.com/dashboard/) and in the JWT Keys section of the corresponding Supabase project settings create a new Standby Key. Pick `HS256 (Shared Secret)` signing algorithm, select to import existing secret and paste the secret there. Make sure not to select `Secret is already Base64 encoded`. Once the key is created, then rotate the keys to make the new one used by Supabase. + +### Configuring a new Open Secret environment + +If there is a new Agicash app environment that needs to have a dedicated Open Secret environment, create a new project in [Open Secret cloud dashboard](https://app.opensecret.cloud/orgs/a92d07fb-3837-42e0-9a83-f109c425391e/projects). Things that need to be configured are: +1. Google OAuth settings - Open Secret needs Google Auth client id and secret. You can get those in the [Google Cloud Console](https://console.cloud.google.com/auth/clients) from the existing client or create a new one. +2. Resend email settings - Open Secret uses [Resend](https://resend.com/) platform to send emails and needs the API key. You can create the API key [here](https://resend.com/api-keys). Additionally you need to configure the email to send from (e.g. noreply@agi.cash) and URL for email verification link (when user receives an email for email address +verification, the email will contain this link). +3. Third-Party JWT Secret - Open Secret creates JWTs that are then used by Supabase to authorize if the user should have access to the requested data. For this to work, Open Secret and Supabase need to share the secret that signed the JWT. To create a secret, go to [Supabase dashboard](https://supabase.com/dashboard/) and in the JWT Keys section of the corresponding Supabase project settings create a new Standby Key (pick `HS256 (Shared Secret)` signing algorithm) and then rotate the keys to make newly created one used by Supabase. Once that is done, paste the same secret into Open Secret dashboard. + +Lastly we need to point the Agicash app to the new Open Secret environment. To do that set `VITE_OPEN_SECRET_CLIENT_ID` env variable to the client ID of the created Open Secret project. + ## Dependencies A dependency should be added only if the benefits are clear. Avoid adding it for trivial stuff. Any dependency added @@ -190,4 +214,29 @@ New e2e test suites should be added to the `e2e` folder and named ` { children: React.ReactNode; } @@ -24,95 +26,150 @@ export function Page({ children, className, ...props }: PageProps) { ); } -interface ClosePageButtonProps extends ViewTransitionLinkProps {} +type PageHeaderItemProps = React.HTMLAttributes & { + children: React.ReactNode; + position: PageHeaderPosition; +}; -export function ClosePageButton({ className, ...props }: ClosePageButtonProps) { +export function PageHeaderItem({ + children, + position, + className, + ...props +}: PageHeaderItemProps) { return ( - - - +
+ {children} +
); } +PageHeaderItem.isHeaderItem = true; +PageHeaderItem.defaultPosition = undefined as PageHeaderPosition | undefined; -export interface PageBackButtonProps extends ViewTransitionLinkProps {} +type ClosePageButtonProps = ViewTransitionLinkProps & { + position?: PageHeaderPosition; +}; -export function PageBackButton({ className, ...props }: PageBackButtonProps) { +/** + * @default position - 'left' + */ +export function ClosePageButton({ + className, + position = 'left', + ...props +}: ClosePageButtonProps) { return ( - - - + + + + + ); } +ClosePageButton.isHeaderItem = true; +ClosePageButton.defaultPosition = 'left' as PageHeaderPosition; -interface PageHeaderTitleProps extends React.HTMLAttributes { - children: React.ReactNode; +export type PageBackButtonProps = ViewTransitionLinkProps & { + position?: PageHeaderPosition; +}; + +/** + * @default position - 'left' + */ +export function PageBackButton({ + className, + position = 'left', + ...props +}: PageBackButtonProps) { + return ( + + + + + + ); } +PageBackButton.isHeaderItem = true; +PageBackButton.defaultPosition = 'left' as PageHeaderPosition; +type PageHeaderTitleProps = React.HTMLAttributes & { + children: React.ReactNode; + position?: PageHeaderPosition; +}; + +/** + * @default position - 'center' + */ export function PageHeaderTitle({ children, className, + position = 'center', ...props }: PageHeaderTitleProps) { return ( -

- {children} -

+ +

+ {children} +

+
); } +PageHeaderTitle.isHeaderItem = true; +PageHeaderTitle.defaultPosition = 'center' as PageHeaderPosition; -interface PageHeaderProps extends React.HTMLAttributes { +type PageHeaderProps = React.HTMLAttributes & { children: React.ReactNode; -} +}; -export function PageHeader({ children, className, ...props }: PageHeaderProps) { - const hasCloseButton = React.Children.toArray(children).some( - (child) => React.isValidElement(child) && child.type === ClosePageButton, - ); - const hasBackButton = React.Children.toArray(children).some( - (child) => React.isValidElement(child) && child.type === PageBackButton, +const isPageHeaderItem = ( + child: React.ReactNode, +): child is React.ReactElement<{ position?: PageHeaderPosition }> => { + return ( + React.isValidElement(child) && + typeof child.type !== 'string' && + 'isHeaderItem' in child.type && + (child.type as { isHeaderItem?: boolean }).isHeaderItem === true ); +}; - if (hasCloseButton && hasBackButton) { +export function PageHeader({ children, className, ...props }: PageHeaderProps) { + const childrenArray = React.Children.toArray(children); + + if (childrenArray.length === 0 || !childrenArray.every(isPageHeaderItem)) { throw new Error( - 'PageHeader cannot have both ClosePageButton and BackButton', + 'PageHeader children must be a component that is marked with isHeaderItem = true', ); } + const getChildrenByPosition = (pos: PageHeaderPosition) => { + return childrenArray.filter((child) => { + if (!React.isValidElement(child)) return false; + const props = child.props as { position?: PageHeaderPosition }; + const componentType = child.type as { + defaultPosition?: PageHeaderPosition; + }; + const position = props.position ?? componentType.defaultPosition; + return position === pos; + }); + }; + + const leftItems = getChildrenByPosition('left'); + const centerItems = getChildrenByPosition('center'); + const rightItems = getChildrenByPosition('right'); + return (
- {/* Close/back button - always on the left */} -
- {React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && - (child.type === ClosePageButton || child.type === PageBackButton), - )} -
- - {/* Title - always in the center */} +
{leftItems}
- {React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && child.type === PageHeaderTitle, - )} -
- - {/* Other elements - on the right */} -
- {React.Children.toArray(children).filter( - (child) => - !React.isValidElement(child) || - (child.type !== PageHeaderTitle && - child.type !== ClosePageButton && - child.type !== PageBackButton), - )} + {centerItems}
+
{rightItems}
); } diff --git a/app/components/wallet-card.tsx b/app/components/wallet-card.tsx new file mode 100644 index 000000000..2a29d69c1 --- /dev/null +++ b/app/components/wallet-card.tsx @@ -0,0 +1,111 @@ +import type * as React from 'react'; +import { cn } from '~/lib/utils'; + +export const CARD_SIZES = { + default: { width: 340, className: 'w-[340px] rounded-[14px]' }, + sm: { width: 140, className: 'w-[140px] rounded-[12px]' }, +} as const; + +export const CARD_ASPECT_RATIO = 2115 / 1334; + +export type WalletCardSize = keyof typeof CARD_SIZES; + +/** + * Props for the WalletCard component. + */ +export type WalletCardProps = { + className?: string; + children: React.ReactNode; + size?: WalletCardSize; +}; + +type WalletCardBackgroundProps = { + src: string; + alt?: string; + className?: string; +}; + +type WalletCardBlankProps = { + className?: string; +}; + +/** + * A card container with a fixed aspect ratio. + */ +export function WalletCard({ + className, + children, + size = 'default', +}: WalletCardProps) { + return ( +
+ {children} + {/* Inner border overlay */} +
+
+ ); +} + +/** + * A blank card background with the same aspect ratio and rounding as the card image. + * Use this instead of WalletCardBackgroundImage when no image is needed. + */ +export function WalletCardBlank({ className }: WalletCardBlankProps) { + return ( +
+ ); +} + +/** + * Overlay content for WalletCard. The content will be displayed on top of the card background. + */ +export function WalletCardOverlay({ + className, + children, + ...props +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +/** + * Lazy-loads background image for WalletCard. + * Displays a skeleton until the image loads. + */ +export function WalletCardBackgroundImage({ + src, + alt = '', + className, +}: WalletCardBackgroundProps) { + return ( + + {alt} + + ); +} diff --git a/app/features/accounts/account-hooks.ts b/app/features/accounts/account-hooks.ts index 3a722acbc..cd0d3169c 100644 --- a/app/features/accounts/account-hooks.ts +++ b/app/features/accounts/account-hooks.ts @@ -12,6 +12,7 @@ import type { AgicashDbAccountWithProofs } from '../agicash-db/database'; import { useUser } from '../user/user-hooks'; import { type Account, + type AccountPurpose, type AccountType, type CashuAccount, type ExtendedAccount, @@ -140,15 +141,54 @@ export const accountsQueryOptions = ({ }); }; -export function useAccounts(select?: { - currency?: Currency; - type?: T; - isOnline?: boolean; -}): UseSuspenseQueryResult[]> { +/** + * Filter options for `useAccounts` hook. + * Results are sorted by creation date (oldest first). + */ +type UseAccountsSelect< + T extends AccountType = AccountType, + P extends AccountPurpose = AccountPurpose, +> = P extends 'gift-card' + ? { + /** Filter by currency (e.g., 'BTC', 'USD') */ + currency?: Currency; + /** Must be 'cashu' when purpose is 'gift-card'. */ + type?: 'cashu'; + /** Filter by online status */ + isOnline?: boolean; + /** Filter for gift-card accounts. Returns `CashuAccount[]` since gift cards are always cashu. */ + purpose: P; + } + : { + /** Filter by currency (e.g., 'BTC', 'USD') */ + currency?: Currency; + /** Filter by account type ('cashu' | 'spark'). Narrows the return type. */ + type?: T; + /** Filter by online status */ + isOnline?: boolean; + /** Filter by purpose. When omitted or 'transactional', any account type is allowed. */ + purpose?: P; + }; + +export function useAccounts( + select: UseAccountsSelect<'cashu', 'gift-card'>, +): UseSuspenseQueryResult[]>; +export function useAccounts< + T extends AccountType = AccountType, + P extends AccountPurpose = AccountPurpose, +>( + select?: UseAccountsSelect, +): UseSuspenseQueryResult[]>; +export function useAccounts< + T extends AccountType = AccountType, + P extends AccountPurpose = AccountPurpose, +>( + select?: UseAccountsSelect, +): UseSuspenseQueryResult[]> { const user = useUser(); const accountRepository = useAccountRepository(); - const { currency, type, isOnline } = select ?? {}; + const { currency, type, isOnline, purpose } = select ?? {}; return useSuspenseQuery({ ...accountsQueryOptions({ userId: user.id, accountRepository }), @@ -158,28 +198,34 @@ export function useAccounts(select?: { (data: Account[]) => { const extendedData = AccountService.getExtendedAccounts(user, data); - if (!currency && !type && isOnline === undefined) { - return extendedData as ExtendedAccount[]; - } + const sortedData = extendedData + .slice() + .sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ) as ExtendedAccount[]; - const filteredData = extendedData.filter( - (account): account is ExtendedAccount => { - if (currency && account.currency !== currency) { - return false; - } - if (type && account.type !== type) { - return false; - } - if (isOnline !== undefined && account.isOnline !== isOnline) { - return false; - } - return true; - }, - ); + if (!currency && !type && isOnline === undefined && !purpose) { + return sortedData; + } - return filteredData; + return sortedData.filter((account) => { + if (currency && account.currency !== currency) { + return false; + } + if (type && account.type !== type) { + return false; + } + if (isOnline !== undefined && account.isOnline !== isOnline) { + return false; + } + if (purpose && account.purpose !== purpose) { + return false; + } + return true; + }); }, - [currency, type, isOnline, user], + [currency, type, isOnline, purpose, user], ), }); } @@ -284,6 +330,20 @@ export function useDefaultAccount() { return defaultAccount; } +/** + * Hook to get an account by ID or fall back to the default account. + * @param accountId - Optional account ID. If not provided or account not found, returns the default account. + * @returns The matching account or the default account. + */ +export function useAccountOrDefault(accountId: string | null) { + const { data: accounts } = useAccounts(); + const defaultAccount = useDefaultAccount(); + + return accountId + ? (accounts.find((a) => a.id === accountId) ?? defaultAccount) + : defaultAccount; +} + export function useAddCashuAccount() { const userId = useUser((x) => x.id); const accountCache = useAccountsCache(); @@ -304,11 +364,14 @@ export function useAddCashuAccount() { } /** - * Hook to get the sum of all account balances for a given currency. + * Hook to get the sum of all transactional account balances for a given currency. * Null balances are ignored. */ export function useBalance(currency: Currency) { - const { data: accounts } = useAccounts({ currency }); + const { data: accounts } = useAccounts({ + currency, + purpose: 'transactional', + }); const balance = accounts.reduce((acc, account) => { const accountBalance = getAccountBalance(account); return accountBalance !== null ? acc.add(accountBalance) : acc; diff --git a/app/features/accounts/account-icons.tsx b/app/features/accounts/account-icons.tsx index 1d3130f88..6cb399d95 100644 --- a/app/features/accounts/account-icons.tsx +++ b/app/features/accounts/account-icons.tsx @@ -1,16 +1,20 @@ -import { LandmarkIcon } from 'lucide-react'; +import { GiftIcon, LandmarkIcon } from 'lucide-react'; import type { ReactNode } from 'react'; import { SparkIcon as SparkIconSvg } from '~/components/spark-icon'; -import type { AccountType } from './account'; +import type { Account, AccountType } from './account'; const CashuIcon = () => ; const SparkIcon = () => ; +const GiftCardIcon = () => ; const iconsByAccountType: Record = { cashu: , spark: , }; -export function AccountTypeIcon({ type }: { type: AccountType }) { - return iconsByAccountType[type]; +export function AccountIcon({ account }: { account: Account }) { + if (account.purpose === 'gift-card') { + return ; + } + return iconsByAccountType[account.type]; } diff --git a/app/features/accounts/account-repository.ts b/app/features/accounts/account-repository.ts index 72d8476b3..e317077bf 100644 --- a/app/features/accounts/account-repository.ts +++ b/app/features/accounts/account-repository.ts @@ -59,7 +59,7 @@ export class AccountRepository { */ async get(id: string, options?: Options): Promise { // Currently we limit the number of proofs returned to 6000 - // We will need to handle that somehow later (e.g. require use to swap when the limit is reaching) + // We will need to handle that somehow later (e.g. require user to swap when the limit is reaching) const query = this.db .from('accounts') .select('*, cashu_proofs(*)') @@ -86,7 +86,7 @@ export class AccountRepository { */ async getAll(userId: string, options?: Options): Promise { // Currently we limit the number of proofs returned to 6000 - // We will need to handle that somehow later (e.g. require use to swap when the limit is reaching) + // We will need to handle that somehow later (e.g. require user to swap when the limit is reaching) const query = this.db .from('accounts') .select('*, cashu_proofs(*)') @@ -133,6 +133,7 @@ export class AccountRepository { currency: accountInput.currency, details, user_id: accountInput.userId, + purpose: accountInput.purpose, }; const query = this.db @@ -166,6 +167,7 @@ export class AccountRepository { id: data.id, name: data.name, currency: data.currency, + purpose: data.purpose, createdAt: data.created_at, version: data.version, }; diff --git a/app/features/accounts/account-selector.tsx b/app/features/accounts/account-selector.tsx index 97660cebf..0a05a5218 100644 --- a/app/features/accounts/account-selector.tsx +++ b/app/features/accounts/account-selector.tsx @@ -12,7 +12,7 @@ import { ScrollArea } from '~/components/ui/scroll-area'; import { cn } from '~/lib/utils'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import { type Account, getAccountBalance } from './account'; -import { AccountTypeIcon } from './account-icons'; +import { AccountIcon } from './account-icons'; import { BalanceOfflineHoverCard } from './balance-offline-hover-card'; export type AccountSelectorOption = T & { @@ -46,7 +46,7 @@ function AccountItem({ account }: { account: AccountSelectorOption }) { return (
- +
{account.name}
diff --git a/app/features/accounts/account.ts b/app/features/accounts/account.ts index f9a5416bf..1aa8c1928 100644 --- a/app/features/accounts/account.ts +++ b/app/features/accounts/account.ts @@ -9,10 +9,18 @@ import type { CashuProof } from './cashu-account'; export type AccountType = 'cashu' | 'spark'; +/** + * The purpose of this account. + * - 'transactional': Regular accounts for sending/receiving payments + * - 'gift-card': Closed-loop accounts for mints that are issuing gift cards + */ +export type AccountPurpose = 'transactional' | 'gift-card'; + export type Account = { id: string; name: string; type: AccountType; + purpose: AccountPurpose; isOnline: boolean; currency: Currency; createdAt: string; @@ -67,6 +75,25 @@ export type ExtendedSparkAccount = ExtendedAccount<'spark'>; export type RedactedAccount = DistributedOmit; export type RedactedCashuAccount = Extract; +/** + * Returns true if the account can send payments through the Lightning network. + * Returns false for test mints and gift-card accounts. + */ +export const canSendToLightning = (account: Account): boolean => { + if (account.type === 'spark') { + return true; + } + return !account.isTestMint && account.purpose === 'transactional'; +}; + +/** + * Returns true if the account can receive payments via the Lightning network. + * Returns false for test mints only. + */ +export const canReceiveFromLightning = (account: Account): boolean => { + return account.type === 'spark' || !account.isTestMint; +}; + export const getAccountBalance = (account: Account) => { if (account.type === 'cashu') { const value = sumProofs(account.proofs); diff --git a/app/features/agicash-db/database.client.ts b/app/features/agicash-db/database.client.ts index 1ed6bed05..016be5169 100644 --- a/app/features/agicash-db/database.client.ts +++ b/app/features/agicash-db/database.client.ts @@ -33,7 +33,7 @@ if (!supabaseAnonKey) { /** * The client-side Supabase database client. - * Cannot be used on the server. Use `agicashDbServer` instead. + * If you need to use a client on the server, which bypasses RLS, use `agicashDbServer` instead. */ export const agicashDbClient = createClient( supabaseUrl, diff --git a/app/features/agicash-db/database.ts b/app/features/agicash-db/database.ts index 387c3fd9a..b272d2353 100644 --- a/app/features/agicash-db/database.ts +++ b/app/features/agicash-db/database.ts @@ -5,7 +5,7 @@ import type { } from 'supabase/database.types'; import type { MergeDeep } from 'type-fest'; import type { Currency, CurrencyUnit } from '~/lib/money'; -import type { AccountType } from '../accounts/account'; +import type { AccountPurpose, AccountType } from '../accounts/account'; import type { CashuProof } from '../accounts/cashu-account'; import type { CashuReceiveQuote } from '../receive/cashu-receive-quote'; import type { CashuTokenSwap } from '../receive/cashu-token-swap'; @@ -138,14 +138,17 @@ export type Database = MergeDeep< Row: { currency: Currency; type: AccountType; + purpose: AccountPurpose; }; Insert: { currency: Currency; type: AccountType; + purpose?: AccountPurpose; }; Update: { currency?: Currency; type?: AccountType; + purpose?: AccountPurpose; }; }; cashu_receive_quotes: { diff --git a/app/features/gift-cards/add-gift-card.tsx b/app/features/gift-cards/add-gift-card.tsx new file mode 100644 index 000000000..e72ed5f2e --- /dev/null +++ b/app/features/gift-cards/add-gift-card.tsx @@ -0,0 +1,135 @@ +import { X } from 'lucide-react'; +import { useState } from 'react'; +import { + Link, + useLocation, + useNavigate, + useViewTransitionState, +} from 'react-router'; +import { + Page, + PageContent, + PageFooter, + PageHeader, + PageHeaderItem, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { + WalletCard, + WalletCardBackgroundImage, +} from '~/components/wallet-card'; +import { useAddCashuAccount } from '~/features/accounts/account-hooks'; +import { useToast } from '~/hooks/use-toast'; +import type { Currency } from '~/lib/money'; +import type { GiftCardInfo } from './use-discover-cards'; + +type AddGiftCardParams = { + name: string; + currency: Currency; + url: string; +}; + +function useAddGiftCard() { + const addCashuAccount = useAddCashuAccount(); + + return ({ name, currency, url }: AddGiftCardParams) => + addCashuAccount({ + name, + currency, + mintUrl: url, + type: 'cashu', + purpose: 'gift-card', + }); +} + +type AddGiftCardProps = { + giftCard: GiftCardInfo; +}; + +/** + * Add Gift Card component - displays the full size gift card image + * and an "Add Card" button to add the discover card to the user's wallet. + */ +export function AddGiftCard({ giftCard }: AddGiftCardProps) { + const [isAdding, setIsAdding] = useState(false); + const addGiftCard = useAddGiftCard(); + const navigate = useNavigate(); + const location = useLocation(); + const { toast } = useToast(); + const isTransitioning = useViewTransitionState('/gift-cards'); + + const handleBack = () => { + navigate('/gift-cards', { + viewTransition: true, + state: location.state, + }); + }; + + const handleAddCard = async () => { + setIsAdding(true); + try { + await addGiftCard({ + name: giftCard.name, + currency: giftCard.currency, + url: giftCard.url, + }); + toast({ + title: 'Success', + description: 'Card added successfully', + duration: 1500, + }); + navigate('/gift-cards'); + } catch (e) { + const message = + e instanceof Error ? e.message : 'Unknown error. Failed to add card.'; + toast({ + title: 'Error', + description: message, + variant: 'destructive', + }); + } finally { + setIsAdding(false); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/features/gift-cards/card-stack-constants.ts b/app/features/gift-cards/card-stack-constants.ts new file mode 100644 index 000000000..68e0b551c --- /dev/null +++ b/app/features/gift-cards/card-stack-constants.ts @@ -0,0 +1,6 @@ +import { CARD_ASPECT_RATIO, CARD_SIZES } from '~/components/wallet-card'; + +export const VERTICAL_CARD_OFFSET_IN_STACK = 52; + +export const CARD_WIDTH = CARD_SIZES.default.width; +export const CARD_HEIGHT = Math.round(CARD_WIDTH / CARD_ASPECT_RATIO); diff --git a/app/features/gift-cards/discover-gift-cards.tsx b/app/features/gift-cards/discover-gift-cards.tsx new file mode 100644 index 000000000..9921a75ee --- /dev/null +++ b/app/features/gift-cards/discover-gift-cards.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef } from 'react'; +import { + Link, + useLocation, + useNavigate, + useViewTransitionState, +} from 'react-router'; +import { z } from 'zod'; +import { + WalletCard, + WalletCardBackgroundImage, +} from '~/components/wallet-card'; +import useUserAgent from '~/hooks/use-user-agent'; +import { cn } from '~/lib/utils'; +import type { GiftCardInfo } from './use-discover-cards'; + +const DiscoverCardsLocationStateSchema = z.object({ + discoverScrollPosition: z.number(), +}); + +/** + * Restores scroll position from navigation state when returning from add-gift-card page. + * Returns the current scroll position for passing to child Link components. + */ +function useRestoreScrollPosition() { + const location = useLocation(); + const scrollRef = useRef(null); + const scrollPositionRef = useRef(0); + + // Restore scroll position from navigation state + useEffect(() => { + const result = DiscoverCardsLocationStateSchema.safeParse(location.state); + if (result.success && scrollRef.current) { + scrollRef.current.scrollLeft = result.data.discoverScrollPosition; + } + }, [location.state]); + + const handleScroll = () => { + if (scrollRef.current) { + scrollPositionRef.current = scrollRef.current.scrollLeft; + } + }; + + return { scrollRef, scrollPositionRef, handleScroll }; +} + +type DiscoverCardLinkProps = { + card: GiftCardInfo; + scrollPositionRef: React.RefObject; + children: React.ReactNode; +}; + +/** + * Link wrapper for a discover card that applies view transition name when navigating forward only. + * Passes current scroll position in navigation state for restoration on back navigation. + */ +function DiscoverCardLink({ + card, + scrollPositionRef, + children, +}: DiscoverCardLinkProps) { + const navigate = useNavigate(); + const to = `/gift-cards/add/${encodeURIComponent(card.url)}/${card.currency}`; + + const isNavigatingToThisCard = useViewTransitionState(to); + + // Use onClick to capture scroll position at click time, not render time + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + navigate(to, { + viewTransition: true, + state: { + discoverScrollPosition: scrollPositionRef.current, + } satisfies z.input, + }); + }; + + return ( + + {children} + + ); +} + +type DiscoverSectionProps = { + giftCards: GiftCardInfo[]; +}; + +/** + * Horizontal scroll carousel of available gift cards for discovery. + * Persists scroll position when navigating to/from add-gift-card page. + */ +export function DiscoverGiftCards({ giftCards }: DiscoverSectionProps) { + const { isMobile } = useUserAgent(); + const isTransitioning = useViewTransitionState('/gift-cards/:accountId'); + const { scrollRef, scrollPositionRef, handleScroll } = + useRestoreScrollPosition(); + + return ( +
+

Discover

+
+
+
+ {giftCards.map((card, index) => ( + + + + + + ))} +
+
+
+
+ ); +} diff --git a/app/features/gift-cards/empty-state.tsx b/app/features/gift-cards/empty-state.tsx new file mode 100644 index 000000000..3826047fd --- /dev/null +++ b/app/features/gift-cards/empty-state.tsx @@ -0,0 +1,30 @@ +import { + WalletCard, + WalletCardBlank, + WalletCardOverlay, +} from '~/components/wallet-card'; + +/** + * Placeholder displayed when user has no gift cards. + */ +export function EmptyState() { + return ( + <> +
+
+ + + +
+

Add a gift card.

+

+ The easiest way to share and spend bitcoin. +

+
+
+
+
+
+ + ); +} diff --git a/app/features/gift-cards/gift-card-details.tsx b/app/features/gift-cards/gift-card-details.tsx new file mode 100644 index 000000000..ed9f20d7f --- /dev/null +++ b/app/features/gift-cards/gift-card-details.tsx @@ -0,0 +1,178 @@ +import { X } from 'lucide-react'; +import { useNavigate, useViewTransitionState } from 'react-router'; + +import { + Page, + PageContent, + PageHeader, + PageHeaderItem, +} from '~/components/page'; +import { Button } from '~/components/ui/button'; +import { getAccountBalance } from '~/features/accounts/account'; +import { useAccounts } from '~/features/accounts/account-hooks'; +import { GiftCardItem } from '~/features/gift-cards/gift-card-item'; +import { getGiftCardImageByUrl } from '~/features/gift-cards/use-discover-cards'; +import { MoneyWithConvertedAmount } from '~/features/shared/money-with-converted-amount'; +import { TransactionList } from '~/features/transactions/transaction-list'; +import { LinkWithViewTransition } from '~/lib/transitions'; +import { + CARD_HEIGHT, + CARD_WIDTH, + VERTICAL_CARD_OFFSET_IN_STACK, +} from './card-stack-constants'; + +type GiftCardDetailsProps = { + cardId: string; +}; + +export default function GiftCardDetails({ cardId }: GiftCardDetailsProps) { + const navigate = useNavigate(); + const isTransitioning = useViewTransitionState('/gift-cards'); + + const { data: giftCardAccounts } = useAccounts({ + purpose: 'gift-card', + }); + + const card = giftCardAccounts.find((c) => c.id === cardId); + const selectedIndex = giftCardAccounts.findIndex((c) => c.id === cardId); + + const handleBack = () => { + navigate('/gift-cards', { viewTransition: true }); + }; + + if (!card) { + return ( + +

Card not found

+
+ ); + } + + const balance = getAccountBalance(card); + + return ( + + + + + + + + + {/* Card area - split-stack positioning */} +
+
+ {/* We render all gift cards for view transitions. */} + {giftCardAccounts.map((account, index) => { + const isSelected = account.id === card.id; + const zIndex = index + 1; + const isAtOrBelowSelected = index <= selectedIndex; + + if (isAtOrBelowSelected) { + const item = ( + + ); + + // Cards at or below selected: transition to the top + return ( +
+ {isSelected ? ( + + ) : ( + item + )} +
+ ); + } + + // Cards above selected: transition to off-screen at bottom + // Use fixed positioning so they don't scroll into view + const offsetBelowViewport = + (index - selectedIndex - 1) * VERTICAL_CARD_OFFSET_IN_STACK; + return ( +
+ +
+ ); + })} +
+
+ +
+ {balance && } + +
+ + + + + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/features/gift-cards/gift-card-item.tsx b/app/features/gift-cards/gift-card-item.tsx new file mode 100644 index 000000000..3c7d41750 --- /dev/null +++ b/app/features/gift-cards/gift-card-item.tsx @@ -0,0 +1,78 @@ +import { useViewTransitionState } from 'react-router'; +import { MoneyDisplay } from '~/components/money-display'; +import { + WalletCard, + WalletCardBackgroundImage, + WalletCardBlank, + WalletCardOverlay, + type WalletCardSize, +} from '~/components/wallet-card'; +import { + type CashuAccount, + getAccountBalance, +} from '~/features/accounts/account'; +import { getDefaultUnit } from '../shared/currencies'; +import { VERTICAL_CARD_OFFSET_IN_STACK } from './card-stack-constants'; + +type GiftCardItemProps = { + account: CashuAccount; + image?: string; + size?: WalletCardSize; + className?: string; + hideOverlayContent?: boolean; +}; + +export function GiftCardItem({ + account, + image, + size, + className, + hideOverlayContent, +}: GiftCardItemProps) { + const isTransitioning = useViewTransitionState('/gift-cards/:accountId'); + const balance = getAccountBalance(account); + const name = + account.wallet.mintInfo?.name ?? + account.mintUrl.replace('https://', '').replace('http://', ''); + + return ( + + {image ? ( + + ) : ( + + )} + +
+
+ {name} + {balance && ( + + )} +
+
+
+
+ ); +} diff --git a/app/features/gift-cards/gift-cards.tsx b/app/features/gift-cards/gift-cards.tsx new file mode 100644 index 000000000..6ed5e206a --- /dev/null +++ b/app/features/gift-cards/gift-cards.tsx @@ -0,0 +1,98 @@ +import { useNavigate, useViewTransitionState } from 'react-router'; +import { + ClosePageButton, + Page, + PageContent, + PageHeader, + PageHeaderTitle, +} from '~/components/page'; +import type { CashuAccount } from '~/features/accounts/account'; +import { useAccounts } from '../accounts/account-hooks'; +import { + CARD_HEIGHT, + CARD_WIDTH, + VERTICAL_CARD_OFFSET_IN_STACK, +} from './card-stack-constants'; +import { DiscoverGiftCards } from './discover-gift-cards'; +import { EmptyState } from './empty-state'; +import { GiftCardItem } from './gift-card-item'; +import { + getGiftCardImageByUrl, + useDiscoverGiftCards, +} from './use-discover-cards'; + +/** + * Gift cards view with discover section and card stack. + * Clicking a card navigates to the card details page with view transitions. + */ +export function GiftCards() { + const { data: accounts } = useAccounts({ + purpose: 'gift-card', + }); + + const navigate = useNavigate(); + const isTransitioning = useViewTransitionState('/gift-cards/:accountId'); + + const hasCards = accounts.length > 0; + const stackedHeight = + CARD_HEIGHT + (accounts.length - 1) * VERTICAL_CARD_OFFSET_IN_STACK; + const giftCardsToDiscover = useDiscoverGiftCards(); + + const handleCardClick = (account: CashuAccount) => { + navigate(`/gift-cards/${account.id}`, { viewTransition: true }); + }; + + return ( + + + + Gift Cards + + + +
+ {giftCardsToDiscover.length > 0 && ( + + )} + + {hasCards ? ( +
+

Your Cards

+
+
+ {accounts.map((account, index) => ( + + ))} +
+
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/app/features/gift-cards/transitions.css b/app/features/gift-cards/transitions.css new file mode 100644 index 000000000..d2b68a6ea --- /dev/null +++ b/app/features/gift-cards/transitions.css @@ -0,0 +1,58 @@ +/* Available cards section - fade out/in */ +::view-transition-old(available-cards), +::view-transition-new(available-cards) { + animation-duration: 0.3s; + animation-timing-function: ease-out; +} + +::view-transition-old(available-cards) { + animation-name: fade-out; +} + +::view-transition-new(available-cards) { + animation-name: fade-in; +} + +/* Discover card - smooth expand/shrink transition */ +::view-transition-old(discover-card), +::view-transition-new(discover-card) { + animation-duration: 0.3s; + animation-timing-function: ease-out; +} + +/* Transactions section - slide up/down with fade */ +@keyframes slide-fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(20px); + } +} + +@keyframes slide-fade-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +::view-transition-old(transactions), +::view-transition-new(transactions) { + animation-duration: 0.35s; + animation-timing-function: ease-out; +} + +::view-transition-old(transactions) { + animation-name: slide-fade-out; +} + +::view-transition-new(transactions) { + animation-name: slide-fade-in; +} diff --git a/app/features/gift-cards/use-discover-cards.ts b/app/features/gift-cards/use-discover-cards.ts new file mode 100644 index 000000000..65bd59d7e --- /dev/null +++ b/app/features/gift-cards/use-discover-cards.ts @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import blockAndBeanCard from '~/assets/gift-cards/blockandbean.agi.cash.webp'; +import compassCoffeeCard from '~/assets/gift-cards/compass.agi.cash.webp'; +import fakeCard from '~/assets/gift-cards/fake.agi.cash.webp'; +import fake4Card from '~/assets/gift-cards/fake4.agi.cash.webp'; +import pinkOwlCoffeeCard from '~/assets/gift-cards/pinkowl.agi.cash.webp'; +import theShackCard from '~/assets/gift-cards/shack.agi.cash.webp'; +import type { Currency } from '~/lib/money'; +import { useAccounts } from '../accounts/account-hooks'; + +export type GiftCardInfo = { + url: string; + name: string; + image: string; + currency: Currency; +}; + +/** + * Hardcoded list of gift cards available for discovery. + */ +export const GIFT_CARDS: GiftCardInfo[] = [ + { + 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://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', + }, +]; + +/** + * Returns the gift card image for a given URL, if one exists. + */ +export function getGiftCardImageByUrl(url: string): string | undefined { + return GIFT_CARDS.find((card) => card.url === url)?.image; +} + +/** + * Returns gift cards that the user has not yet added. + */ +export function useDiscoverGiftCards(): GiftCardInfo[] { + const { data: cashuAccounts } = useAccounts({ type: 'cashu' }); + + return useMemo(() => { + const existingGiftCardAccounts = new Set( + cashuAccounts.map((account) => `${account.mintUrl}:${account.currency}`), + ); + + return GIFT_CARDS.filter( + (mint) => !existingGiftCardAccounts.has(`${mint.url}:${mint.currency}`), + ); + }, [cashuAccounts]); +} diff --git a/app/features/receive/receive-cashu-token-hooks.ts b/app/features/receive/receive-cashu-token-hooks.ts index 52d16a63b..2efb9c231 100644 --- a/app/features/receive/receive-cashu-token-hooks.ts +++ b/app/features/receive/receive-cashu-token-hooks.ts @@ -230,8 +230,7 @@ export function useReceiveCashuTokenAccounts( return { selectableAccounts: possibleDestinationAccounts.map(toOption), receiveAccount: receiveAccount ? toOption(receiveAccount) : null, - isCrossMintSwapDisabled: sourceAccount.isTestMint, - sourceAccount: sourceAccount, + sourceAccount, setReceiveAccount, addAndSetReceiveAccount, }; @@ -312,6 +311,7 @@ function getSparkAccountPlaceholder(): ReceiveCashuTokenAccount & { id: 'spark-account-placeholder-id', name: 'Bitcoin', type: 'spark', + purpose: 'transactional', isOnline: true, currency: 'BTC', wallet: createSparkWalletStub( diff --git a/app/features/receive/receive-cashu-token-service.ts b/app/features/receive/receive-cashu-token-service.ts index 488468694..97648e840 100644 --- a/app/features/receive/receive-cashu-token-service.ts +++ b/app/features/receive/receive-cashu-token-service.ts @@ -2,9 +2,11 @@ import type { Token } from '@cashu/cashu-ts'; import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import { areMintUrlsEqual, getCashuProtocolUnit } from '~/lib/cashu'; import type { Currency } from '~/lib/money'; -import type { - ExtendedAccount, - ExtendedCashuAccount, +import { + type ExtendedAccount, + type ExtendedCashuAccount, + canReceiveFromLightning, + canSendToLightning, } from '../accounts/account'; import { cashuMintValidator, @@ -41,6 +43,7 @@ export class ReceiveCashuTokenService { const baseAccount = { id: 'cashu-account-placeholder-id', type: 'cashu' as const, + purpose: wallet.purpose, name: mintUrl.replace('https://', '').replace('http://', ''), mintUrl, createdAt: new Date().toISOString(), @@ -141,25 +144,21 @@ export class ReceiveCashuTokenService { /** * Returns the default receive account, or null if the token cannot be received. - * If the token is from a test mint, the source account will be returned if it is selectable, because tokens from test mint can only be claimed to the same mint. - * If the token is not from a test mint, the preferred receive account will be returned if it is selectable. + * If the token is from a test mint or gift card, the source account will be returned if it is selectable. + * If the token is not from a test mint or gift card, the preferred receive account will be returned if it is selectable. * If the preferred receive account is not selectable, the default account will be returned. * @param sourceAccount The source account of the token * @param possibleDestinationAccounts The possible destination accounts (cashu and spark) * @param preferredReceiveAccountId The preferred receive account id - * @returns + * @returns The default account to receive the token, or null if none available */ static getDefaultReceiveAccount( sourceAccount: CashuAccountWithTokenFlags, possibleDestinationAccounts: ReceiveCashuTokenAccount[], preferredReceiveAccountId?: string, ): ReceiveCashuTokenAccount | null { - if (sourceAccount.isTestMint) { - if (!sourceAccount.canReceive) { - return null; - } - // Tokens sourced from test mint can only be claimed to the same mint - return sourceAccount; + if (!canSendToLightning(sourceAccount)) { + return sourceAccount.canReceive ? sourceAccount : null; } const preferredReceiveAccount = possibleDestinationAccounts.find( @@ -193,13 +192,14 @@ export class ReceiveCashuTokenService { ...account, isSource: false, isUnknown: false, - canReceive: account.type === 'spark' || !account.isTestMint, + canReceive: canReceiveFromLightning(account), })); } /** * Returns the possible destination accounts that can receive the token from the source account. - * If the source account is from a test mint, the only account that can receive the token is the same source account. + * If the source account is from a test mint or is a gift card account, the only account that + * can receive the token is the same source account. * @param sourceAccount The source account of the token * @param otherAccounts The other user's accounts * @returns The possible destination accounts @@ -208,8 +208,7 @@ export class ReceiveCashuTokenService { sourceAccount: CashuAccountWithTokenFlags, otherAccounts: ReceiveCashuTokenAccount[], ): ReceiveCashuTokenAccount[] { - if (sourceAccount.isTestMint) { - // Tokens sourced from test mint can only be claimed to the same mint + if (!canSendToLightning(sourceAccount)) { return sourceAccount.canReceive ? [sourceAccount] : []; } return [sourceAccount, ...otherAccounts].filter( diff --git a/app/features/receive/receive-cashu-token.tsx b/app/features/receive/receive-cashu-token.tsx index 529b6361d..20ed90a0b 100644 --- a/app/features/receive/receive-cashu-token.tsx +++ b/app/features/receive/receive-cashu-token.tsx @@ -26,6 +26,8 @@ import { useNavigateWithViewTransition, } from '~/lib/transitions'; import { AccountSelector } from '../accounts/account-selector'; +import { GiftCardItem } from '../gift-cards/gift-card-item'; +import { getGiftCardImageByUrl } from '../gift-cards/use-discover-cards'; import { tokenToMoney } from '../shared/cashu'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; @@ -108,12 +110,13 @@ export default function ReceiveToken({ const { selectableAccounts, receiveAccount, - isCrossMintSwapDisabled, sourceAccount, setReceiveAccount, addAndSetReceiveAccount, } = useReceiveCashuTokenAccounts(token, preferredReceiveAccountId); + const isGiftCardSource = sourceAccount.purpose === 'gift-card'; + const isReceiveAccountKnown = receiveAccount?.isUnknown === false; const { mutateAsync: createCashuTokenSwap } = useCreateCashuTokenSwap(); @@ -192,12 +195,20 @@ export default function ReceiveToken({
{claimableToken && receiveAccount ? (
- + {isGiftCardSource ? ( + + ) : ( + + )}
) : ( Send {canShare() && ( - + + + )} diff --git a/app/features/settings/accounts/add-mint-form.tsx b/app/features/settings/accounts/add-mint-form.tsx index a1df45840..abcdfaf22 100644 --- a/app/features/settings/accounts/add-mint-form.tsx +++ b/app/features/settings/accounts/add-mint-form.tsx @@ -19,7 +19,7 @@ import { } from '~/features/shared/cashu'; import { useUser } from '~/features/user/user-hooks'; import { useToast } from '~/hooks/use-toast'; -import { getCashuProtocolUnit } from '~/lib/cashu'; +import { getCashuProtocolUnit, getMintPurpose } from '~/lib/cashu'; import type { Currency } from '~/lib/money'; import { LinkWithViewTransition } from '~/lib/transitions'; @@ -69,11 +69,16 @@ export function AddMintForm() { const onSubmit = async (data: FormValues) => { try { + const mintInfo = await queryClient.fetchQuery( + mintInfoQueryOptions(data.mintUrl), + ); + const purpose = getMintPurpose(mintInfo); await addAccount({ name: data.name, currency: data.currency, mintUrl: data.mintUrl, type: 'cashu', + purpose, }); toast({ title: 'Success', diff --git a/app/features/settings/accounts/all-accounts.tsx b/app/features/settings/accounts/all-accounts.tsx index b1ebaead6..c23127efb 100644 --- a/app/features/settings/accounts/all-accounts.tsx +++ b/app/features/settings/accounts/all-accounts.tsx @@ -15,7 +15,10 @@ import type { Currency } from '~/lib/money'; import { LinkWithViewTransition } from '~/lib/transitions'; function CurrencyAccounts({ currency }: { currency: Currency }) { - const { data: accounts } = useAccounts({ currency }); + const { data: accounts } = useAccounts({ + currency, + purpose: 'transactional', + }); return (
diff --git a/app/features/settings/settings.tsx b/app/features/settings/settings.tsx index a0d4f61d9..fa532ac71 100644 --- a/app/features/settings/settings.tsx +++ b/app/features/settings/settings.tsx @@ -10,6 +10,7 @@ import { PageContent, PageFooter, PageHeader, + PageHeaderItem, } from '~/components/page'; import { Button } from '~/components/ui/button'; import { SettingsNavButton } from '~/features/settings/ui/settings-nav-button'; @@ -19,7 +20,7 @@ import { canShare, shareContent } from '~/lib/share'; import { LinkWithViewTransition } from '~/lib/transitions'; import { cn } from '~/lib/utils'; import { useDefaultAccount } from '../accounts/account-hooks'; -import { AccountTypeIcon } from '../accounts/account-icons'; +import { AccountIcon } from '../accounts/account-icons'; import { ColorModeToggle } from '../theme/color-mode-toggle'; import { useSignOut } from '../user/auth'; import { useUser } from '../user/user-hooks'; @@ -97,9 +98,11 @@ export default function Settings() { {canShare() && ( - + + + )} @@ -111,7 +114,7 @@ export default function Settings() { - + {defaultAccount.name} diff --git a/app/features/settings/ui/settings-view-header.tsx b/app/features/settings/ui/settings-view-header.tsx index f5255acd7..0567589f5 100644 --- a/app/features/settings/ui/settings-view-header.tsx +++ b/app/features/settings/ui/settings-view-header.tsx @@ -2,6 +2,7 @@ import { PageBackButton, type PageBackButtonProps, PageHeader, + PageHeaderItem, PageHeaderTitle, } from '~/components/page'; @@ -18,7 +19,7 @@ export const SettingsViewHeader = ({ {title && {title}} - {children} + {children && {children}} ); }; diff --git a/app/features/shared/money-with-converted-amount.tsx b/app/features/shared/money-with-converted-amount.tsx index f7e25eee3..07fbe0de3 100644 --- a/app/features/shared/money-with-converted-amount.tsx +++ b/app/features/shared/money-with-converted-amount.tsx @@ -22,6 +22,21 @@ const getCurrencyToConvertTo = (money: Money, otherCurrency: Currency) => { return money.currency; }; +const sizeConfig = { + lg: { + primary: 'lg', + secondary: 'sm', + minHeight: 'min-h-[116px]', + skeletonClass: 'h-6 w-32', + }, + md: { + primary: 'md', + secondary: 'xs', + minHeight: 'min-h-[96px]', + skeletonClass: 'h-5 w-26', + }, +} as const; + /** * Displays money amount and its amount converted to the other currency. * If other currency is not provided, it will default to USD if money currency is USD, and default to BTC otherwise. @@ -36,6 +51,7 @@ export const MoneyWithConvertedAmount = ({ ? defaultFiatCurrency : 'BTC', variant = 'default', + size = 'lg', }: { /** * Money amount to display. @@ -49,6 +65,10 @@ export const MoneyWithConvertedAmount = ({ * Variant to display the money amount and converted amount. */ variant?: 'default' | 'inline'; + /** + * Size of the display. + */ + size?: 'lg' | 'md'; }) => { const currencyToConvertTo = getCurrencyToConvertTo(money, otherCurrency); const exchangeRateQuery = useExchangeRate( @@ -69,17 +89,21 @@ export const MoneyWithConvertedAmount = ({ } : null; + const config = sizeConfig[size]; + return variant === 'default' ? ( -
- +
+ {conversionData && ( <> - {conversionData.loading && } + {conversionData.loading && ( + + )} {conversionData.convertedMoney && ( )} diff --git a/app/features/transactions/transaction-details.tsx b/app/features/transactions/transaction-details.tsx index bf27e5303..4de4d3ddf 100644 --- a/app/features/transactions/transaction-details.tsx +++ b/app/features/transactions/transaction-details.tsx @@ -14,7 +14,7 @@ import type { Transaction } from '~/features/transactions/transaction'; import { useToast } from '~/hooks/use-toast'; import { LinkWithViewTransition } from '~/lib/transitions'; import { useAccount } from '../accounts/account-hooks'; -import { AccountTypeIcon } from '../accounts/account-icons'; +import { AccountIcon } from '../accounts/account-icons'; import { getErrorMessage } from '../shared/error'; import { MoneyWithConvertedAmount } from '../shared/money-with-converted-amount'; import { @@ -157,7 +157,7 @@ export function TransactionDetails({
- + {account?.name}
diff --git a/app/features/transactions/transaction-hooks.ts b/app/features/transactions/transaction-hooks.ts index 6cae6f4d8..bc3a34fbd 100644 --- a/app/features/transactions/transaction-hooks.ts +++ b/app/features/transactions/transaction-hooks.ts @@ -87,6 +87,9 @@ class TransactionsCache { this.queryClient.invalidateQueries({ queryKey: [TransactionsCache.Key], }), + this.queryClient.invalidateQueries({ + queryKey: [allTransactionsQueryKey], + }), this.queryClient.invalidateQueries({ queryKey: [TransactionsCache.UnacknowledgedCountKey], }), @@ -113,18 +116,19 @@ export function useTransaction(id: string) { const PAGE_SIZE = 25; -export function useTransactions() { +export function useTransactions(accountId?: string) { const userId = useUser((user) => user.id); const transactionRepository = useTransactionRepository(); const result = useInfiniteQuery({ - queryKey: [allTransactionsQueryKey], + queryKey: [allTransactionsQueryKey, accountId], initialPageParam: null, queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => { const result = await transactionRepository.list({ userId, cursor: pageParam, pageSize: PAGE_SIZE, + accountId, }); return { transactions: result.transactions, @@ -163,16 +167,20 @@ const acknowledgeTransactionInHistoryCache = ( queryClient: QueryClient, transaction: Transaction, ) => { - queryClient.setQueryData< + // Update all transaction query caches (both unified and account-specific) + const queries = queryClient.getQueriesData< InfiniteData<{ transactions: Transaction[]; nextCursor: Cursor | null; }> - >([allTransactionsQueryKey], (old) => { - if (!old) return old; - return { - ...old, - pages: old.pages.map((page) => ({ + >({ queryKey: [allTransactionsQueryKey] }); + + queries.forEach(([queryKey, data]) => { + if (!data) return; + + queryClient.setQueryData(queryKey, { + ...data, + pages: data.pages.map((page) => ({ ...page, transactions: page.transactions.map((tx) => tx.id === transaction.id && tx.acknowledgmentStatus === 'pending' @@ -180,7 +188,7 @@ const acknowledgeTransactionInHistoryCache = ( : tx, ), })), - }; + }); }); }; diff --git a/app/features/transactions/transaction-list.tsx b/app/features/transactions/transaction-list.tsx index 70640992f..6cc9ab88c 100644 --- a/app/features/transactions/transaction-list.tsx +++ b/app/features/transactions/transaction-list.tsx @@ -7,9 +7,9 @@ import { useMemo, useRef, } from 'react'; +import { useLocation } from 'react-router'; import { SparkIcon } from '~/components/spark-icon'; import { Card } from '~/components/ui/card'; -import { ScrollArea } from '~/components/ui/scroll-area'; import { useTransactionAckStatusStore } from '~/features/transactions/transaction-ack-status-store'; import { useIsVisible } from '~/hooks/use-is-visible'; import { @@ -17,6 +17,7 @@ import { VIEW_TRANSITION_DURATION_MS, } from '~/lib/transitions'; import { useLatest } from '~/lib/use-latest'; +import { cn } from '~/lib/utils'; import { getDefaultUnit } from '../shared/currencies'; import type { Transaction } from './transaction'; import { @@ -152,6 +153,7 @@ function TransactionRow({ }: { transaction: Transaction; }) { + const location = useLocation(); const { mutate: acknowledgeTransaction } = useAcknowledgeTransaction(); const { setAckStatus, statuses: ackStatuses } = useTransactionAckStatusStore(); @@ -170,7 +172,10 @@ function TransactionRow({ return ( data?.pages.flatMap((page) => page.transactions) ?? [], @@ -326,7 +334,9 @@ export function TransactionList() { } return ( - +
)} - +
); } diff --git a/app/features/transactions/transaction-repository.ts b/app/features/transactions/transaction-repository.ts index f4bfe5c20..45021f9eb 100644 --- a/app/features/transactions/transaction-repository.ts +++ b/app/features/transactions/transaction-repository.ts @@ -32,6 +32,7 @@ type ListOptions = Options & { userId: string; cursor?: Cursor; pageSize?: number; + accountId?: string; }; export class TransactionRepository { @@ -60,6 +61,7 @@ export class TransactionRepository { userId, cursor = null, pageSize = 25, + accountId, abortSignal, }: ListOptions) { const query = this.db.rpc('list_transactions', { @@ -68,6 +70,7 @@ export class TransactionRepository { p_cursor_created_at: cursor?.createdAt, p_cursor_id: cursor?.id, p_page_size: pageSize, + p_account_id: accountId, }); if (abortSignal) { diff --git a/app/features/user/user-hooks.tsx b/app/features/user/user-hooks.tsx index 4cc18ed5f..a833644f5 100644 --- a/app/features/user/user-hooks.tsx +++ b/app/features/user/user-hooks.tsx @@ -83,6 +83,7 @@ export const defaultAccounts = [ name: 'Bitcoin', network: 'MAINNET', isDefault: true, + purpose: 'transactional', }, ...(isDevelopmentMode ? ([ @@ -93,6 +94,7 @@ export const defaultAccounts = [ mintUrl: 'https://testnut.cashu.space', isTestMint: true, isDefault: false, + purpose: 'transactional', }, { type: 'cashu', @@ -101,6 +103,7 @@ export const defaultAccounts = [ mintUrl: 'https://testnut.cashu.space', isTestMint: true, isDefault: true, + purpose: 'transactional', }, ] as const) : []), diff --git a/app/features/user/user-repository.ts b/app/features/user/user-repository.ts index fe438a6e6..e2d9e8f94 100644 --- a/app/features/user/user-repository.ts +++ b/app/features/user/user-repository.ts @@ -176,6 +176,7 @@ export class WriteUserRepository { type: account.type, currency: account.currency, is_default: account.isDefault ?? false, + purpose: account.purpose, details: (() => { if (account.type === 'cashu') { return CashuAccountDetailsDbDataSchema.parse({ @@ -274,6 +275,7 @@ export class ReadUserDefaultAccountRepository { id: data.id, name: data.name, currency: data.currency, + purpose: data.purpose, createdAt: data.created_at, version: data.version, }; diff --git a/app/lib/cashu/PROTOCOL_EXTENSIONS.md b/app/lib/cashu/PROTOCOL_EXTENSIONS.md index bd10072af..3152fae16 100644 --- a/app/lib/cashu/PROTOCOL_EXTENSIONS.md +++ b/app/lib/cashu/PROTOCOL_EXTENSIONS.md @@ -17,6 +17,7 @@ Example: { "...other fields", "agicash": { + "closed_loop": true, "minting_fee": { "type": "basis_points", "value": 100 @@ -25,6 +26,20 @@ Example: } ``` +## Closed Loop + +The `closed_loop` field is a boolean that indicates whether the mint operates in closed-loop mode. +When `true`, the mint will only process payments to destinations within its loop. The loop may include +invoices generated by the mint itself or other configured destinations. + +```json +{ + "agicash": { + "closed_loop": true + } +} +``` + ## Minting Fees (extends NUT-23) Agicash mints can be configured to charge a fee for minting using the bolt11 payment method. diff --git a/app/lib/cashu/protocol-extensions.ts b/app/lib/cashu/protocol-extensions.ts index 679277658..8ecc344ff 100644 --- a/app/lib/cashu/protocol-extensions.ts +++ b/app/lib/cashu/protocol-extensions.ts @@ -4,10 +4,12 @@ */ import type { + GetInfoResponse, LockedMintQuoteResponse, MintQuoteResponse, PartialMintQuoteResponse, } from '@cashu/cashu-ts'; +import type { MintInfo } from './types'; /** * Extension type for mint quote responses that include a deposit fee. @@ -21,6 +23,34 @@ type MintQuoteFee = { fee?: number; }; +/** + * Agicash-specific mint info extension. + * This is included in the mint's info response under the "agicash" key. + */ +export type AgicashMintExtension = { + /** + * When true, the mint operates in closed-loop mode and will only process + * payments to destinations within its loop. + */ + closed_loop?: boolean; +}; + +/** + * Extended GetInfoResponse that includes agicash-specific extensions. + * This is the raw response from the mint's info endpoint with agicash extensions. + */ +export type ExtendedGetInfoResponse = GetInfoResponse & { + agicash?: AgicashMintExtension; +}; + +/** + * Extended MintInfo that includes agicash-specific extensions. + * This extends the cashu-ts MintInfo class type with the agicash extension. + */ +export type ExtendedMintInfo = MintInfo & { + agicash?: AgicashMintExtension; +}; + export type ExtendedMintQuoteResponse = MintQuoteResponse & MintQuoteFee; export type ExtendedLockedMintQuoteResponse = LockedMintQuoteResponse & diff --git a/app/lib/cashu/utils.ts b/app/lib/cashu/utils.ts index 6eed735a0..4a379d7da 100644 --- a/app/lib/cashu/utils.ts +++ b/app/lib/cashu/utils.ts @@ -9,6 +9,7 @@ import { type MintQuoteResponse, OutputData, type Proof, + type SwapMethod, } from '@cashu/cashu-ts'; import Big from 'big.js'; import type { DistributedOmit } from 'type-fest'; @@ -16,6 +17,7 @@ import { decodeBolt11 } from '~/lib/bolt11'; import type { Currency, CurrencyUnit } from '../money'; import type { ExtendedLockedMintQuoteResponse, + ExtendedMintInfo, ExtendedMintQuoteResponse, ExtendedPartialMintQuoteResponse, } from './protocol-extensions'; @@ -76,6 +78,22 @@ export const getCashuProtocolUnit = (currency: Currency) => { return currencyToCashuProtocolUnit[currency]; }; +/** + * Determines the purpose of a mint based on its info. + */ +export const getMintPurpose = ( + mintInfo: ExtendedMintInfo | null | undefined, +): 'gift-card' | 'transactional' => { + // TODO: This should check this.mintInfo?.agicash?.closed_loop once Agicash mints change to that + // TODO: Should the mint explicitly signal the purpose? + const bolt11Method = mintInfo?.nuts?.[5]?.methods?.find( + (m) => m.method === 'bolt11', + ) as SwapMethod & { options?: { internal_melts_only?: boolean } }; + return bolt11Method?.options?.internal_melts_only + ? 'gift-card' + : 'transactional'; +}; + export const getWalletCurrency = (wallet: CashuWallet) => { const unit = wallet.unit as keyof typeof cashuProtocolUnitToCurrency; if (!cashuProtocolUnitToCurrency[unit]) { @@ -93,6 +111,7 @@ export const getWalletCurrency = (wallet: CashuWallet) => { * - Overridden mint quote methods that return extended response types as defined in [protocol-extensions.ts](./protocol-extensions.ts) * - Direct access to the bip39 seed * - Fee estimation utilities for receiving operations + * - Access to agicash-specific mint extensions (e.g., closed loop mode) */ export class ExtendedCashuWallet extends CashuWallet { private _bip39Seed: Uint8Array | undefined; @@ -112,6 +131,21 @@ export class ExtendedCashuWallet extends CashuWallet { return this._bip39Seed; } + /** + * Returns the mint info with agicash-specific extensions. + * This overrides the base class mintInfo getter to return the extended type. + */ + override get mintInfo(): ExtendedMintInfo { + return super.mintInfo as ExtendedMintInfo; + } + + /** + * Gets the purpose of this mint based on its configuration. + */ + get purpose(): 'gift-card' | 'transactional' { + return getMintPurpose(this.mintInfo); + } + /** * This method overrides the createMintQuote method from CashuWallet to return ExtendedMintQuoteResponse */ diff --git a/app/lib/feature-flags.ts b/app/lib/feature-flags.ts index 6b33d2615..be4ef53b2 100644 --- a/app/lib/feature-flags.ts +++ b/app/lib/feature-flags.ts @@ -1,5 +1,6 @@ const FEATURE_FLAGS = { GUEST_SIGNUP: import.meta.env.VITE_FF_GUEST_SIGNUP === 'true', + GIFT_CARDS: import.meta.env.VITE_FF_GIFT_CARDS === 'true', } as const; /** diff --git a/app/routes/_protected._index.tsx b/app/routes/_protected._index.tsx index a3ea408b3..66f372630 100644 --- a/app/routes/_protected._index.tsx +++ b/app/routes/_protected._index.tsx @@ -1,7 +1,12 @@ -import { Clock, UserCircle2 } from 'lucide-react'; +import { Clock, GiftIcon, UserCircle2 } from 'lucide-react'; import type { LinksFunction } from 'react-router'; import agicashIcon192 from '~/assets/icon-192x192.png'; -import { Page, PageContent, PageHeader } from '~/components/page'; +import { + Page, + PageContent, + PageHeader, + PageHeaderItem, +} from '~/components/page'; import { Button } from '~/components/ui/button'; import { useBalance, @@ -12,6 +17,7 @@ import { InstallPwaPrompt } from '~/features/pwa/install-pwa-prompt'; import { MoneyWithConvertedAmount } from '~/features/shared/money-with-converted-amount'; import { useHasTransactionsPendingAck } from '~/features/transactions/transaction-hooks'; import { useUser } from '~/features/user/user-hooks'; +import { useFeatureFlag } from '~/lib/feature-flags'; import { LinkWithViewTransition } from '~/lib/transitions'; export const links: LinksFunction = () => [ @@ -26,11 +32,24 @@ export default function Index() { const defaultUsdAccountId = useUser((user) => user.defaultUsdAccountId); const defaultCurrency = useDefaultAccount().currency; const hasTransactionsPendingAck = useHasTransactionsPendingAck(); + const giftCardsEnabled = useFeatureFlag('GIFT_CARDS'); return ( - -
+ + + {giftCardsEnabled && ( + + + + )} + + + -
+
@@ -65,7 +84,7 @@ export default function Index() {
)} -
+
; +} diff --git a/app/routes/_protected.gift-cards.$cardId.tsx b/app/routes/_protected.gift-cards.$cardId.tsx new file mode 100644 index 000000000..3be0d001d --- /dev/null +++ b/app/routes/_protected.gift-cards.$cardId.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 GiftCardLayout() { + const [store] = useState(() => createTransactionAckStatusStore()); + + return ; +} diff --git a/app/routes/_protected.gift-cards._index.tsx b/app/routes/_protected.gift-cards._index.tsx new file mode 100644 index 000000000..b64bfffc5 --- /dev/null +++ b/app/routes/_protected.gift-cards._index.tsx @@ -0,0 +1,5 @@ +import { GiftCards } from '~/features/gift-cards/gift-cards'; + +export default function GiftCardsRoute() { + return ; +} diff --git a/app/routes/_protected.gift-cards.tsx b/app/routes/_protected.gift-cards.tsx new file mode 100644 index 000000000..06b8d11eb --- /dev/null +++ b/app/routes/_protected.gift-cards.tsx @@ -0,0 +1,6 @@ +import { Outlet } from 'react-router'; +import '~/features/gift-cards/transitions.css'; + +export default function GiftCardsLayout() { + return ; +} diff --git a/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx b/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx new file mode 100644 index 000000000..4a40781d4 --- /dev/null +++ b/app/routes/_protected.gift-cards_.add.$mintUrl.$currency.tsx @@ -0,0 +1,29 @@ +import { AddGiftCard } from '~/features/gift-cards/add-gift-card'; +import { GIFT_CARDS } from '~/features/gift-cards/use-discover-cards'; +import { LoadingScreen } from '~/features/loading/LoadingScreen'; +import { NotFoundError } from '~/features/shared/error'; +import type { Route } from './+types/_protected.gift-cards_.add.$mintUrl.$currency'; + +export const clientLoader = ({ params }: Route.ComponentProps) => { + const { mintUrl, currency } = params; + const giftCard = GIFT_CARDS.find( + (card) => card.url === mintUrl && card.currency === currency, + ); + if (!giftCard) { + throw new NotFoundError('Gift card not found'); + } + + return { giftCard }; +}; + +clientLoader.hydrate = true as const; + +export function HydrateFallback() { + return ; +} + +export default function AddGiftCardRoute({ loaderData }: Route.ComponentProps) { + const { giftCard } = loaderData; + + return ; +} diff --git a/app/routes/_protected.receive.tsx b/app/routes/_protected.receive.tsx index 30b2343d9..7eb99e7f3 100644 --- a/app/routes/_protected.receive.tsx +++ b/app/routes/_protected.receive.tsx @@ -1,12 +1,14 @@ -import { Outlet } from 'react-router'; -import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { Outlet, useSearchParams } from 'react-router'; +import { useAccountOrDefault } from '~/features/accounts/account-hooks'; import { ReceiveProvider } from '~/features/receive'; export default function ReceiveLayout() { - const defaultAccount = useDefaultAccount(); + const [searchParams] = useSearchParams(); + const accountId = searchParams.get('accountId'); + const initialAccount = useAccountOrDefault(accountId); return ( - + ); diff --git a/app/routes/_protected.send.tsx b/app/routes/_protected.send.tsx index d0c44418d..7e8ec9750 100644 --- a/app/routes/_protected.send.tsx +++ b/app/routes/_protected.send.tsx @@ -1,12 +1,14 @@ -import { Outlet } from 'react-router'; -import { useDefaultAccount } from '~/features/accounts/account-hooks'; +import { Outlet, useSearchParams } from 'react-router'; +import { useAccountOrDefault } from '~/features/accounts/account-hooks'; import { SendProvider } from '~/features/send'; export default function SendLayout() { - const defaultAccount = useDefaultAccount(); + const [searchParams] = useSearchParams(); + const accountId = searchParams.get('accountId'); + const initialAccount = useAccountOrDefault(accountId); return ( - + ); diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 9329032a2..857f2e857 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -16,6 +16,7 @@ export type Database = { details: Json id: string name: string + purpose: string type: string user_id: string version: number @@ -26,6 +27,7 @@ export type Database = { details: Json id?: string name: string + purpose?: string type: string user_id: string version?: number @@ -36,6 +38,7 @@ export type Database = { details?: Json id?: string name?: string + purpose?: string type?: string user_id?: string version?: number @@ -1372,6 +1375,7 @@ export type Database = { get_account_with_proofs: { Args: { p_account_id: string }; Returns: Json } list_transactions: { Args: { + p_account_id?: string p_cursor_created_at?: string p_cursor_id?: string p_cursor_state_sort_order?: number @@ -1551,6 +1555,7 @@ export type Database = { name: string | null details: Json | null is_default: boolean | null + purpose: string | null } add_cashu_proofs_and_update_account_result: { account: Json | null diff --git a/supabase/migrations/20260105200858_add-account-id-filter-to-list-transactions.sql b/supabase/migrations/20260105200858_add-account-id-filter-to-list-transactions.sql new file mode 100644 index 000000000..4a22bdd77 --- /dev/null +++ b/supabase/migrations/20260105200858_add-account-id-filter-to-list-transactions.sql @@ -0,0 +1,63 @@ +-- Description: +-- This migration updates the list_transactions function to support optional account ID filtering. +-- It also optimizes the query logic by splitting it into seperate branches using dynamic SQL. +-- +-- Affected Functions: wallet.list_transactions +-- Affected Indexes: wallet.transactions (new index idx_transactions_user_account_filtered_state_ordered) +-- ======================================== + +-- Drop the existing function +drop function if exists wallet.list_transactions(uuid, integer, timestamptz, uuid, integer); + +-- Create a composite index to support efficient filtering by account_id while maintaining sort order +create index if not exists idx_transactions_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'); + +-- Recreate the function with optional account_id parameter using dynamic SQL +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 +) +returns setof wallet.transactions +language plpgsql +stable +security definer +as $$ +declare + query text; +begin + -- Build base query + query := ' + select t.* + from wallet.transactions t + where t.user_id = $1 + and t.state in (''PENDING'', ''COMPLETED'', ''REVERSED'')'; + + -- Add account filter if provided + if p_account_id is not null then + query := query || ' and t.account_id = $6'; + end if; + + -- Add cursor filter if provided + if p_cursor_created_at is not null then + query := query || ' and (t.state_sort_order, t.created_at, t.id) < ($2, $3, $4)'; + end if; + + -- Add ordering and limit + query := query || ' order by t.state_sort_order desc, t.created_at desc, t.id desc limit $5'; + + return query execute query + using p_user_id, p_cursor_state_sort_order, p_cursor_created_at, p_cursor_id, p_page_size, p_account_id; +end; +$$; diff --git a/supabase/migrations/20260112180000_add_account_purpose.sql b/supabase/migrations/20260112180000_add_account_purpose.sql new file mode 100644 index 000000000..1554e6b22 --- /dev/null +++ b/supabase/migrations/20260112180000_add_account_purpose.sql @@ -0,0 +1,174 @@ +-- Migration: Add Account Purpose Column +-- +-- Purpose: +-- 1. Add 'purpose' column to accounts table to distinguish account types +-- 2. Update account_input composite type to include purpose +-- 3. Update upsert_user_with_accounts function to handle purpose +-- +-- Affected Objects: +-- - wallet.accounts (table - new column) +-- - wallet.account_input (composite type - new field) +-- - wallet.upsert_user_with_accounts (function) +-- +-- Changes: +-- 1. Added purpose column with values: 'transactional' (default) or 'gift-card' +-- 2. Modified account_input type to include purpose field +-- 3. Modified upsert function to insert purpose when creating accounts +-- +-- Special Considerations: +-- - Existing accounts default to 'transactional' purpose +-- - The purpose distinguishes regular accounts from closed-loop gift card accounts + +-- Add purpose column to accounts table with default value for existing rows +alter table wallet.accounts + add column purpose text not null default 'transactional'; + +-- Add check constraint to validate purpose values +alter table wallet.accounts + add constraint accounts_purpose_check + check (purpose in ('transactional', 'gift-card')); + +-- Recreate account_input type with purpose field +-- Must drop and recreate since ALTER TYPE doesn't support adding fields to composite types +drop type if exists wallet.account_input cascade; +create type wallet.account_input as ( + "type" text, + "currency" text, + "name" text, + "details" jsonb, + "is_default" boolean, + "purpose" text +); + +-- Drop old function signature before recreating +drop function if exists wallet.upsert_user_with_accounts(uuid, text, boolean, wallet.account_input[], text, text, text); + +-- Recreate upsert_user_with_accounts function with purpose support +create or replace function wallet.upsert_user_with_accounts( + p_user_id uuid, + p_email text, + p_email_verified boolean, + p_accounts wallet.account_input[], + p_cashu_locking_xpub text, + p_encryption_public_key text, + p_spark_identity_public_key text +) +returns wallet.upsert_user_with_accounts_result +language plpgsql +as $function$ +declare + result_user wallet.users; + result_accounts jsonb[]; + usd_account_id uuid := null; + btc_account_id uuid := null; + placeholder_btc_account_id uuid := gen_random_uuid(); +begin + -- Insert user with placeholder default_btc_account_id. The FK constraint is deferred, + -- so it won't be checked until transaction commit. We'll update it with the real + -- account ID after creating accounts. + insert into wallet.users (id, email, email_verified, cashu_locking_xpub, encryption_public_key, spark_identity_public_key, default_currency, default_btc_account_id) + values (p_user_id, p_email, p_email_verified, p_cashu_locking_xpub, p_encryption_public_key, p_spark_identity_public_key, 'BTC', placeholder_btc_account_id) + on conflict (id) do update set + email = coalesce(excluded.email, wallet.users.email), + email_verified = excluded.email_verified; + + select * + into result_user + from wallet.users u + where u.id = p_user_id + for update; + + with accounts_with_proofs as ( + select + a.*, + coalesce( + jsonb_agg(to_jsonb(cp)) filter (where cp.id is not null), + '[]'::jsonb + ) as cashu_proofs + from + wallet.accounts a + left join wallet.cashu_proofs cp on cp.account_id = a.id and cp.state = 'UNSPENT' + where a.user_id = p_user_id + group by a.id + ) + select array_agg( + jsonb_set( + to_jsonb(awp), + '{cashu_proofs}', + awp.cashu_proofs + ) + ) + into result_accounts + from accounts_with_proofs awp; + + if result_accounts is not null then + return (result_user, result_accounts); + end if; + + if array_length(p_accounts, 1) is null then + raise exception + using + hint = 'INVALID_ARGUMENT', + message = 'p_accounts cannot be an empty array'; + end if; + + if not exists (select 1 from unnest(p_accounts) as acct where acct.currency = 'BTC' and acct.type = 'spark') then + raise exception + using + hint = 'INVALID_ARGUMENT', + message = 'At least one BTC Spark account is required'; + end if; + + with + inserted_accounts as ( + insert into wallet.accounts (user_id, type, currency, name, details, purpose) + select + p_user_id, + acct.type, + acct.currency, + acct.name, + acct.details, + acct.purpose + from unnest(p_accounts) as acct + returning * + ), + accounts_with_default_flag as ( + select + ia.*, + coalesce(acct."is_default", false) as "is_default" + from + inserted_accounts ia + join unnest(p_accounts) as acct on + ia.type = acct.type and + ia.currency = acct.currency and + ia.name = acct.name and + ia.details = acct.details + ) + select + array_agg( + jsonb_set( + to_jsonb(awd), + '{cashu_proofs}', + '[]'::jsonb + ) + ), + (array_agg(awd.id) filter (where awd.currency = 'USD' and awd."is_default"))[1], + (array_agg(awd.id) filter (where awd.currency = 'BTC' and awd."is_default"))[1] + into result_accounts, usd_account_id, btc_account_id + from accounts_with_default_flag awd; + + update wallet.users u + set + default_usd_account_id = coalesce(usd_account_id, u.default_usd_account_id), + default_btc_account_id = coalesce(btc_account_id, u.default_btc_account_id), + default_currency = case + when btc_account_id is not null then 'BTC' + when usd_account_id is not null then 'USD' + else u.default_currency + end + where id = p_user_id + returning * into result_user; + + return (result_user, result_accounts); +end; +$function$; diff --git a/tailwind.config.ts b/tailwind.config.ts index bdbe3da56..a47f1c1c1 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from 'tailwindcss'; +import plugin from 'tailwindcss/plugin'; export default { darkMode: ['class'], @@ -105,5 +106,18 @@ export default { }, }, }, - plugins: [require('tailwindcss-animate')], + plugins: [ + require('tailwindcss-animate'), + plugin(({ addUtilities }) => { + addUtilities({ + '.scrollbar-none': { + '-ms-overflow-style': 'none', + 'scrollbar-width': 'none', + '&::-webkit-scrollbar': { + display: 'none', + }, + }, + }); + }), + ], } satisfies Config; diff --git a/tools/convert-to-webp.sh b/tools/convert-to-webp.sh new file mode 100755 index 000000000..7257b3cff --- /dev/null +++ b/tools/convert-to-webp.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Converts PNG files to WebP format +# Usage: convert-to-webp [file2.png ...] +# Or: convert-to-webp --dir + +set -e + +QUALITY=80 + +show_help() { + echo "Convert PNG images to WebP format" + echo "" + echo "Usage:" + echo " convert-to-webp [file2.png ...] Convert specific files" + echo " convert-to-webp --dir Convert all PNGs in directory" + echo "" + echo "Options:" + echo " -q, --quality <0-100> WebP quality (default: 80)" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " convert-to-webp image.png" + echo " convert-to-webp --dir app/assets/gift-cards" + echo " convert-to-webp -q 85 --dir app/assets/gift-cards" +} + +# Check if cwebp is available +if ! command -v cwebp &>/dev/null; then + echo "❌ cwebp not found. Install with: brew install webp" + echo " Or run via nix: nix shell nixpkgs#libwebp --command $0 $*" + exit 1 +fi + +# Parse arguments +FILES=() +DIR="" + +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + -q|--quality) + QUALITY="$2" + shift 2 + ;; + --dir) + DIR="$2" + shift 2 + ;; + *) + FILES+=("$1") + shift + ;; + esac +done + +# If --dir was provided, find all PNGs in that directory +if [ -n "$DIR" ]; then + if [ ! -d "$DIR" ]; then + echo "❌ Directory not found: $DIR" + exit 1 + fi + for file in "$DIR"/*.png; do + [ -f "$file" ] && FILES+=("$file") + done +fi + +if [ ${#FILES[@]} -eq 0 ]; then + echo "❌ No PNG files specified" + echo "" + show_help + exit 1 +fi + +CONVERTED=0 +for file in "${FILES[@]}"; do + if [ ! -f "$file" ]; then + echo "⚠️ Skipping (not found): $file" + continue + fi + + if [[ "$file" != *.png ]]; then + echo "⚠️ Skipping (not a PNG): $file" + continue + fi + + output="${file%.png}.webp" + echo "🔄 Converting: $file → $output" + + # Get original size before conversion + original_size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null) + + cwebp -q "$QUALITY" "$file" -o "$output" 2>/dev/null + + # Show size comparison + new_size=$(stat -f%z "$output" 2>/dev/null || stat -c%s "$output" 2>/dev/null) + savings=$((100 - (new_size * 100 / original_size))) + + # Delete the original PNG + rm "$file" + + echo " ✅ Done (${savings}% smaller, PNG deleted)" + + CONVERTED=$((CONVERTED + 1)) +done + +echo "" +echo "🎉 Converted $CONVERTED file(s) to WebP" diff --git a/vite-env.d.ts b/vite-env.d.ts index 994fa49fd..be2c69a93 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -20,6 +20,7 @@ interface ImportMetaEnv { readonly VITE_CASHU_MINT_BLOCKLIST: string | undefined; // Feature flags readonly VITE_FF_GUEST_SIGNUP: string | undefined; + readonly VITE_FF_GIFT_CARDS: string | undefined; } // biome-ignore lint/correctness/noUnusedVariables: this is needed to augment the ImportMeta type