diff --git a/CLAUDE.md b/CLAUDE.md index 8a031c54..7fb097a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ Before diving in, these are the most common issues: 10. **Free tier has two axes**: `useFreeUploadLimit()` returns `{ freeUploadLimitBytes, freeTier }`. `freeUploadLimitBytes` (== `freeTier.maxItemBytes`) is the per-item size cap used by `isFileFree()`. `freeTier.lifetimeBytes` / `freeTier.ipBytes` are separate lifetime/IP quotas (0 = uncapped) shown in UI messaging — don't conflate them with the per-item limit. +11. **The store's `creditBalance` is spendable, not shareable**: `useCreditBalance` fills it from the payment service's `effectiveBalance`, which includes credits *other wallets shared with this one*. Those can pay for uploads but cannot be shared onward; only `winc` can. Anything that offers to share or hand credits on reads `toShareableBalance()` (`utils/shareableCredits.ts`), as `ShareCreditsPanel` does. Reading `creditBalance` there offers more than the service will approve. + ## Architecture Overview ### Application Structure @@ -769,7 +771,7 @@ its own surface or horizontal padding — that's a frame inside a frame and double inset. Panels written for a standalone page (the `/topup` fiat and crypto steps) carry both and gate them on being embedded. -All modal chrome lives in `components/modals/BaseModal.tsx` (~22 consumers). It provides Escape-to-close, a Tab focus trap, `role="dialog"`/`aria-modal`, body scroll lock, focus restore on close, and a labelled close button — **don't reimplement any of that in a consumer.** Two things to know: +All modal chrome lives in `components/modals/BaseModal.tsx` (~28 consumers). It provides Escape-to-close, a Tab focus trap, `role="dialog"`/`aria-modal`, body scroll lock, focus restore on close, and a labelled close button — **don't reimplement any of that in a consumer.** Two things to know: - `showCloseButton` defaults to **true**. A dismiss affordance is opt-out, not opt-in. - Modals **nest** (`WalletSelectionModal` renders `BlockingMessageModal` inside its own `BaseModal`), so BaseModal keeps a module-level stack: only the topmost instance answers Escape and traps Tab, and the scroll lock lifts only when the last one closes. Preserve that if you touch it. @@ -787,6 +789,10 @@ All modal chrome lives in `components/modals/BaseModal.tsx` (~22 consumers). It **Every routed screen has the same body width, on every device.** `Layout` wraps the outlet in `max-w-site`, and each screen's outermost element is `px-4 sm:px-6`, which also gives phones the same side gutter everywhere. **A screen must not add its own `mx-auto max-w-*` cap.** Name Detail (`max-w-5xl`), Pages (`max-w-6xl`) and two views inside Pages (`max-w-3xl`, `max-w-xl`) each had one, so they read as narrower than Upload or Deploy for no reason. Narrow measures are still right *inside* a screen: a centred empty-state sentence (`max-w-md`), prose (`max-w-prose`), modals, and the Pages live preview, which is a phone-sized frame on purpose. Browse is the one deliberate exception: it reclaims the layout padding to give its viewer more room. +### Docs links + +Every link to documentation is `` (`components/DocsLink.tsx`), placed beside what it explains (under the page description, or at the end of the sentence) and named for its topic ("How ArNS works"), never "Learn more". No bottom-of-page resource blocks or card grids. The console had five styles for this before 4.11.2 and three of its docs URLs were dead, one of them a soft 404 (`docs.ar.io` returns 200 with a "404: This page could not be found" title), so check a new URL's ``, not its status code. Rules and exceptions: `docs/STYLE_GUIDE.md` § Docs links. + ### Key Files - `src/styles/globals.css` - CSS custom properties diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 2ab24a84..3840b604 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -532,6 +532,24 @@ Back, "Pay with card instead", "Register another name": a plain text link (`text-sm text-primary hover:underline`, or `text-foreground/80 hover:text-foreground` for a quieter one), not a button shape. +### Docs links +A link to documentation is always `<DocsLink>` (`src/components/DocsLink.tsx`): +primary, medium weight, underline on hover, a small external-link icon, and a +new tab. Don't hand-write one. + +- **Beside what it explains.** Under a page's description, or at the end of + the sentence it explains. No "Learn more" or "Resources" block at the bottom + of a page, and no card grid of docs links. +- **Name the topic.** "How ArNS works", "What are credits?", not "Learn more". +- **One per topic.** Two links to the same page is one link. +- **Never a button.** A docs link is a tertiary action, so it sits beside the + button it supports ("Find a name" · How ArNS works), not as a second button. +- Standalone, pass `className="text-xs"`; inside a sentence it takes the + sentence's size. + +Exceptions: the Gateway page's Developer Resources panel (there the links are +the content), the landing page's marketing sections, and site navigation. + ### Small Action Button (section and list headers) The buttons beside a section or list title ("Export CSV", "Check Status", "Edit", "Manage", "Add record"), and the small primary action that sits with them ("Create page"). Always use `actionButtonClass` from `src/components/actionButton.ts`; don't hand-write these classes. diff --git a/src/components/DocsLink.tsx b/src/components/DocsLink.tsx new file mode 100644 index 00000000..7f13af59 --- /dev/null +++ b/src/components/DocsLink.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import { ExternalLink } from 'lucide-react'; + +/** + * The one way to link to documentation (see "Docs links" in + * docs/STYLE_GUIDE.md). + * + * It sits beside what it explains, under a page's description or at the end + * of the relevant sentence, never in a "Learn more" block at the bottom of a + * page. Name the topic ("How ArNS works", "What are credits?") rather than + * "Learn more". Standalone, pass `text-xs`; inside a sentence it takes the + * sentence's size. + * + * Primary purple reaches only ~1.9:1 on the dark washes, so this is for light + * surfaces; a docs link on a dark one needs the `on-dark` accent instead. + */ +export default function DocsLink({ + href, + children, + className = '', +}: { + href: string; + children: ReactNode; + className?: string; +}) { + return ( + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className={`inline-flex items-center gap-1 whitespace-nowrap font-medium text-primary hover:underline ${className}`} + > + {children} + <ExternalLink className="h-3 w-3 flex-shrink-0" aria-hidden="true" /> + <span className="sr-only"> (opens in a new tab)</span> + </a> + ); +} diff --git a/src/components/panels/DeploySitePanel.tsx b/src/components/panels/DeploySitePanel.tsx index 46455a3a..38d42303 100644 --- a/src/components/panels/DeploySitePanel.tsx +++ b/src/components/panels/DeploySitePanel.tsx @@ -26,6 +26,7 @@ import { actionButtonClass } from '@/components/actionButton'; import AssignedDomainLinks from '@/components/AssignedDomainLinks'; import { assignedLabel, useArnsHost } from '@/hooks/useArnsHost'; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; // Helper function moved outside component to prevent recreation on every render function getFileIcon(filename: string) { @@ -2512,19 +2513,16 @@ export default function DeploySitePanel() { </li> </ul> - <div className="flex flex-col sm:flex-row gap-3"> + <div className="flex flex-col sm:flex-row sm:items-center gap-3"> <button onClick={() => navigate('/arns')} - className={`${buttonClass('secondary', 'lg')} flex-1`} + className={buttonClass('secondary', 'lg')} > Find a name </button> - <button - onClick={() => window.open('https://docs.ar.io/learn/arns', '_blank', 'noopener,noreferrer')} - className={`${buttonClass('secondary', 'lg')} flex-1`} - > + <DocsLink href="https://docs.ar.io/learn/arns" className="text-sm self-center"> How ArNS works - </button> + </DocsLink> </div> </div> </div> diff --git a/src/components/panels/InfoPanel.tsx b/src/components/panels/InfoPanel.tsx deleted file mode 100644 index e6a704b4..00000000 --- a/src/components/panels/InfoPanel.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useStore } from '../../store/useStore'; -import { getTurboBalance, wincToCredits } from '../../utils'; -import { useWincForOneGiB } from '../../hooks/useWincForOneGiB'; -import { useFreeUploadLimit, useFreeStatus, freeTierSummary } from '../../hooks/useFreeUploadLimit'; -import Faq from '../Faq'; - -export default function InfoPanel() { - const { address, walletType, x402OnlyMode } = useStore(); - const { freeUploadLimitBytes } = useFreeUploadLimit(); - const { bytesRemaining } = useFreeStatus(); - // x402-only bundlers have no free tier — don't advertise one. - const effectiveFreeLimit = x402OnlyMode ? 0 : freeUploadLimitBytes; - const freeSummary = freeTierSummary(effectiveFreeLimit, bytesRemaining); - const [balance, setBalance] = useState<number>(0); - const [loading, setLoading] = useState(false); - const wincForOneGiB = useWincForOneGiB(); - - useEffect(() => { - if (!address || !walletType) return; - - setLoading(true); - getTurboBalance(address, walletType) - .then((result) => { - setBalance(wincToCredits(result.winc)); - }) - .catch(console.error) - .finally(() => setLoading(false)); - }, [address, walletType]); - - const estimatedStorage = wincForOneGiB - ? (balance * 1_000_000_000_000) / Number(wincForOneGiB) - : 0; - - return ( - <div className="p-8"> - <div className="mb-8"> - <h2 className="text-2xl font-extrabold mb-2">Your Account</h2> - <p className="text-foreground/80">Manage credits, upload files, and more</p> - </div> - - {/* Balance Card */} - <div className="bg-card rounded-2xl p-4 sm:p-6 mb-4 sm:mb-6"> - <div className="text-sm text-foreground/80 mb-1">Current Balance</div> - {loading ? ( - <div className="animate-pulse h-8 bg-border/20 rounded w-32"></div> - ) : ( - <> - <div className="text-3xl font-bold mb-2"> - {balance.toLocaleString()} - <span className="text-lg text-foreground/80 ml-2">Credits</span> - </div> - <div className="text-sm text-foreground/80"> - ≈ {estimatedStorage.toFixed(2)} GiB storage - </div> - </> - )} - </div> - - {/* Quick Stats */} - <div className="space-y-4"> - <div className="bg-card rounded-2xl p-4"> - <div className="text-sm text-foreground/80 mb-1">Upload Speed</div> - <div className="font-semibold">860 tx/sec</div> - </div> - - <div className="bg-card rounded-2xl p-4"> - <div className="text-sm text-foreground/80 mb-1">Network Status</div> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 bg-success rounded-full animate-pulse"></div> - <span className="font-semibold">Online</span> - </div> - </div> - - {freeSummary && ( - <div className="bg-card rounded-2xl p-4"> - <div className="text-sm text-foreground/80 mb-1">Free Tier</div> - <div className={`font-semibold ${bytesRemaining === 0 ? 'text-foreground/60' : ''}`}> - {freeSummary} - </div> - </div> - )} - </div> - - {/* Resources */} - <div className="mt-8 pt-8 border-t border-border/20"> - <h3 className="mb-4">Resources</h3> - <div className="space-y-2"> - <a - href="https://docs.ardrive.io/docs/turbo/turbo-sdk/" - target="_blank" - rel="noopener noreferrer" - className="block text-foreground/80 hover:text-foreground transition-colors" - > - SDK Documentation → - </a> - <a - href="https://cookbook.ar.io/guides/posting-transactions/turbo.html" - target="_blank" - rel="noopener noreferrer" - className="block text-foreground/80 hover:text-foreground transition-colors" - > - Code Examples → - </a> - <a - href="https://github.com/ardriveapp/turbo-sdk" - target="_blank" - rel="noopener noreferrer" - className="block text-foreground/80 hover:text-foreground transition-colors" - > - GitHub Repository → - </a> - </div> - </div> - - {/* FAQ */} - <Faq /> - </div> - ); -} \ No newline at end of file diff --git a/src/components/panels/ShareCreditsList.tsx b/src/components/panels/ShareCreditsList.tsx new file mode 100644 index 00000000..69ace572 --- /dev/null +++ b/src/components/panels/ShareCreditsList.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { formatWalletAddress } from '../../utils'; +import { formatCreditsFloor, type ShareRow } from '../../utils/shareableCredits'; +import { buttonClass } from '@/components/button'; + +// Rows a phone shows before "Show all"; desktop scrolls the whole list inside +// a card locked to the form's height. +const PHONE_ROWS = 3; + +const fmt = formatCreditsFloor; + +const expiryLabel = (row: ShareRow) => { + if (!row.soonestExpiry) return 'Never expires'; + const date = new Date(row.soonestExpiry).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return row.count > 1 ? `Next expiry ${date}` : `Expires ${date}`; +}; + +type Tab = 'given' | 'received'; + +/** + * The right-hand card on Share Credits: the shares this wallet has made, and + * the ones made to it, as two tabs. Given rows are already grouped by + * recipient (`toShareRows`), so Revoke acts on exactly what its row shows. + */ +export default function ShareCreditsList({ + given, + received, + sharedOutWinc, + receivedWinc, + onRevoke, +}: { + given: ShareRow[]; + received: ShareRow[]; + sharedOutWinc: bigint; + receivedWinc: bigint; + onRevoke: (address: string) => Promise<void>; +}) { + const [tab, setTab] = useState<Tab>(given.length === 0 && received.length > 0 ? 'received' : 'given'); + const [expanded, setExpanded] = useState(false); + const [confirming, setConfirming] = useState<string | null>(null); + const [revoking, setRevoking] = useState<string | null>(null); + const [revokeError, setRevokeError] = useState(''); + + const rows = tab === 'given' ? given : received; + const pick = (next: Tab) => { + setTab(next); + setExpanded(false); + setConfirming(null); + setRevokeError(''); + }; + + const revoke = async (address: string) => { + setRevoking(address); + setRevokeError(''); + try { + await onRevoke(address); + setConfirming(null); + } catch (err) { + setRevokeError(err instanceof Error ? err.message : 'Failed to revoke'); + } finally { + setRevoking(null); + } + }; + + const tabClass = (active: boolean) => + `rounded-xl text-sm transition-colors ${active ? 'bg-lavender font-semibold text-foreground' : 'font-medium text-foreground/80 hover:text-foreground'}`; + + return ( + <section + aria-label="Shares" + className="flex flex-col gap-3 rounded-2xl border border-border/20 bg-card p-4 sm:p-6 lg:absolute lg:inset-0" + > + <div role="tablist" aria-label="Shares" className="grid h-12 flex-shrink-0 grid-cols-2 gap-1 rounded-2xl border border-border/20 bg-background p-1"> + <button type="button" role="tab" id="tab-given" aria-controls="panel-shares" aria-selected={tab === 'given'} onClick={() => pick('given')} className={tabClass(tab === 'given')}> + Your shares · {given.length} + </button> + <button type="button" role="tab" id="tab-received" aria-controls="panel-shares" aria-selected={tab === 'received'} onClick={() => pick('received')} className={tabClass(tab === 'received')}> + Shared with you · {received.length} + </button> + </div> + + {rows.length > 0 && ( + <div className="flex flex-shrink-0 flex-wrap justify-between gap-x-2 text-sm text-foreground/70"> + <span>{tab === 'given' ? `${fmt(sharedOutWinc)} unspent` : `${fmt(receivedWinc)} left to spend`}</span> + <span>{tab === 'given' ? 'Revoke returns the unspent part' : 'Spend only, can’t be re-shared'}</span> + </div> + )} + + {revokeError && <div className="flex-shrink-0 rounded-xl bg-error/10 p-3 text-sm text-error">{revokeError}</div>} + + <div + id="panel-shares" + role="tabpanel" + aria-labelledby={tab === 'given' ? 'tab-given' : 'tab-received'} + tabIndex={0} + className="flex flex-col gap-2 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" + > + {rows.length === 0 && ( + <p className="py-6 text-center text-sm text-foreground/70"> + {tab === 'given' + ? 'You haven’t shared any credits yet. Shares you make show up here, with how much has been spent.' + : 'No wallet has shared credits with you.'} + </p> + )} + {rows.map((row, i) => { + const pct = row.approvedWinc > 0n ? Number((row.usedWinc * 100n) / row.approvedWinc) : 0; + return ( + <div + key={row.address} + className={`flex-shrink-0 flex-col gap-2 rounded-2xl bg-background px-3.5 py-3 ${i >= PHONE_ROWS && !expanded ? 'hidden lg:flex' : 'flex'}`} + > + <div className="flex min-h-[2.75rem] items-center justify-between gap-2 lg:min-h-8"> + <span className="font-mono text-sm" title={row.address}> + {formatWalletAddress(row.address, 4)} + {row.count > 1 && <span className="ml-1.5 font-body text-xs text-foreground/60">{row.count} shares</span>} + </span> + {tab === 'given' && confirming !== row.address && ( + <button type="button" onClick={() => setConfirming(row.address)} className={`${buttonClass('secondary', 'xs')} min-h-[2.75rem] lg:min-h-0`}> + Revoke + </button> + )} + </div> + <div className="h-1.5 overflow-hidden rounded-full bg-subtle-border" aria-hidden="true"> + <div className="h-full bg-primary" style={{ width: `${pct}%` }} /> + </div> + <div className="flex justify-between gap-2 text-xs text-foreground/70"> + <span> + {fmt(row.usedWinc)} of {fmt(row.approvedWinc)} {tab === 'given' ? 'spent' : 'used'} + </span> + <span>{expiryLabel(row)}</span> + </div> + {confirming === row.address && ( + <div className="mt-1 flex flex-col gap-2 border-t border-border/20 pt-3"> + <p className="text-sm text-foreground/80"> + Revoke the unspent {fmt(row.remainingWinc)} credits + {row.count > 1 ? ` across all ${row.count} shares` : ''}? This wallet can’t spend them after this. + </p> + <div className="flex gap-2"> + <button type="button" onClick={() => revoke(row.address)} disabled={revoking !== null} className={buttonClass('danger', 'xs')}> + {revoking === row.address ? 'Revoking…' : 'Revoke'} + </button> + <button type="button" onClick={() => setConfirming(null)} disabled={revoking !== null} className={buttonClass('secondary', 'xs')}> + Cancel + </button> + </div> + </div> + )} + </div> + ); + })} + </div> + + {rows.length > PHONE_ROWS && !expanded && ( + <button type="button" onClick={() => setExpanded(true)} className={`${buttonClass('secondary', 'md')} w-full lg:hidden`}> + Show all {rows.length} + </button> + )} + </section> + ); +} diff --git a/src/components/panels/ShareCreditsPanel.tsx b/src/components/panels/ShareCreditsPanel.tsx index c4160792..ce7e5bfd 100644 --- a/src/components/panels/ShareCreditsPanel.tsx +++ b/src/components/panels/ShareCreditsPanel.tsx @@ -1,4 +1,5 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { TurboFactory, TurboAuthenticatedClient, @@ -7,22 +8,62 @@ import { import { useStore } from '../../store/useStore'; import { wincPerCredit } from '../../constants'; import { useTurboConfig } from '../../hooks/useTurboConfig'; -import { ExternalLink, Shield, ArrowRight, Share2, Book, Lightbulb, Code, CheckCircle, Wallet } from 'lucide-react'; -import { promptSignIn } from '../../utils'; +import { Share2, Check, Wallet, Loader2 } from 'lucide-react'; +import DocsLink from '@/components/DocsLink'; +import { promptSignIn, getTurboBalance, formatWalletAddress } from '../../utils'; +import { + toShareableBalance, + toApprovedWinc, + toShareRows, + isSameWallet, + parseCreditsToWinc, + formatCreditsFloor, + shareFailureMessage, +} from '../../utils/shareableCredits'; import { useWincForOneGiB } from '../../hooks/useWincForOneGiB'; import { validateWalletAddress, getWalletTypeLabel } from '../../utils/addressValidation'; import { useEthereumTurboClient } from '../../hooks/useEthereumTurboClient'; import { useWallet } from '@solana/wallet-adapter-react'; import { buttonClass } from '@/components/button'; +import ShareCreditsList from './ShareCreditsList'; + +// "5", "0.25", "1.5": the amount as the user would say it. +const plainCredits = (credits: number) => String(Number(credits.toFixed(4))); +const creditsWord = (credits: number) => (credits === 1 ? 'credit' : 'credits'); + +const MIN_SHARE = 0.01; +const MIN_SHARE_WINC = BigInt(wincPerCredit) / 100n; + +type Expiry = 'never' | '1h' | '1d' | '1w' | 'custom'; +const EXPIRY_OPTIONS: { id: Expiry; label: string; short: string; seconds: number }[] = [ + { id: 'never', label: 'Never', short: 'Never', seconds: 0 }, + { id: '1h', label: '1 hour', short: '1h', seconds: 3_600 }, + { id: '1d', label: '1 day', short: '1d', seconds: 86_400 }, + { id: '1w', label: '1 week', short: '1w', seconds: 604_800 }, + { id: 'custom', label: 'Custom', short: 'Custom', seconds: 0 }, +]; +const CUSTOM_UNITS = { hours: 3_600, days: 86_400 } as const; + +const formatTime = (at: number) => + new Date(at).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +// A preview: before sharing, the end is "this long from now". +const formatEnd = (seconds: number) => formatTime(Date.now() + seconds * 1000); -interface Approval { - approvedAddress: string; - approvedWincAmount: string; - expirationDate?: string; +interface SharedResult { + credits: number; + recipient: string; + /** When the share ends, fixed when it was made; null when it never does. */ + endsAt: number | null; } export default function ShareCreditsPanel() { - const { address, walletType, creditBalance } = useStore(); + const { address, walletType, configMode } = useStore(); + const paymentAvailable = useStore((s) => s.isPaymentServiceAvailable()); const wincForOneGiB = useWincForOneGiB(); const turboConfig = useTurboConfig(); const { createEthereumTurboClient } = useEthereumTurboClient(); @@ -64,99 +105,148 @@ export default function ShareCreditsPanel() { throw new Error(`Unsupported wallet type: ${walletType}`); } }; - const [creditAmount, setCreditAmount] = useState(1); - const [creditAmountInput, setCreditAmountInput] = useState('1'); - const [approvedAddress, setApprovedAddress] = useState(''); - const [approvedAddressInput, setApprovedAddressInput] = useState(''); - const [recipientWalletType, setRecipientWalletType] = useState<'arweave' | 'ethereum' | 'solana' | 'unknown' | null>(null); - const [expiresBySeconds, setExpiresBySeconds] = useState(0); + + const [amountInput, setAmountInput] = useState(''); + const [recipientInput, setRecipientInput] = useState(''); + const [recipientTouched, setRecipientTouched] = useState(false); + const [expiry, setExpiry] = useState<Expiry>('never'); + const [customInput, setCustomInput] = useState(''); + const [customUnit, setCustomUnit] = useState<keyof typeof CUSTOM_UNITS>('days'); const [sending, setSending] = useState(false); const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); + const [shared, setShared] = useState<SharedResult | null>(null); - // Revoke state - const [revokeAddress, setRevokeAddress] = useState(''); - const [revoking, setRevoking] = useState(false); + /* + Fetched here rather than read from the store's `creditBalance`, which is + `effectiveBalance`: it includes credits other wallets shared with this one, + which can be spent but not re-shared. Reading it offered more than the + service would approve. The 'credit-balance' prefix keeps this query in the + set that `refresh-balance` invalidates. The same response lists the + approvals in both directions. + */ + const { + data: shareBalance, + isError: balanceError, + refetch: refetchBalance, + } = useQuery({ + queryKey: ['credit-balance', 'shareable', address, walletType, configMode], + enabled: !!address && !!walletType && paymentAvailable, + staleTime: 30_000, + queryFn: async () => { + const response = await getTurboBalance(address!, walletType!); + // getTurboBalance parses any response body, so a 5xx error body would + // otherwise read as a zero balance. A real balance always has `winc`. + if (response?.winc === undefined) throw new Error('Balance unavailable'); + return { + ...toShareableBalance(response), + given: toShareRows(response.givenApprovals, 'given'), + received: toShareRows(response.receivedApprovals, 'received'), + }; + }, + }); + const shareableWinc = shareBalance?.shareableWinc ?? 0n; + const receivedWinc = shareBalance?.receivedWinc ?? 0n; + // Figures are rounded down (formatCreditsFloor) so one the user copies back + // into the form never exceeds what can actually be shared. + const shareableText = formatCreditsFloor(shareableWinc); + const receivedText = formatCreditsFloor(receivedWinc); - // Use creditBalance from store instead of fetching separately - const balance = useMemo(() => { - if (creditBalance > 0) { - return { winc: String(creditBalance * wincPerCredit) }; - } - return null; - }, [creditBalance]); + // Amount + // Decided in winc, parsed exactly from the text: as floats, the Max figure + // can read a hair over the balance. `amount` is for display only. + const amountWinc = parseCreditsToWinc(amountInput); + const amount = amountWinc !== undefined ? Number(amountInput.trim()) : NaN; + const amountEntered = amountInput.trim() !== ''; + const amountOver = !!shareBalance && amountWinc !== undefined && amountWinc > shareableWinc; + const amountUnder = amountEntered && amountWinc !== undefined && amountWinc < MIN_SHARE_WINC; + const amountMalformed = amountEntered && amountWinc === undefined; + const amountValid = !!shareBalance && amountWinc !== undefined && !amountUnder && !amountOver; + const wincPerGiB = wincForOneGiB ? Number(wincForOneGiB) : NaN; + const gib = amountValid && Number.isFinite(wincPerGiB) && wincPerGiB > 0 ? (amount * wincPerCredit) / wincPerGiB : null; + + // Recipient, checked as it's typed. A wrong format is only called out once + // the field is left, so it doesn't shout at a half-pasted address. + const recipient = recipientInput.trim(); + const recipientCheck = recipient ? validateWalletAddress(recipient) : null; + const recipientType = recipientCheck?.isValid && recipientCheck.type !== 'unknown' ? recipientCheck.type : null; + const recipientIsSelf = !!recipientType && isSameWallet(recipient, address); + const recipientInvalid = !!recipient && !recipientType && recipientTouched; + const recipientValid = !!recipientType && !recipientIsSelf; + + // Expiry + const customValue = Number(customInput); + const expirySeconds = + expiry === 'custom' + ? Number.isFinite(customValue) && customValue > 0 + ? Math.round(customValue * CUSTOM_UNITS[customUnit]) + : 0 + : EXPIRY_OPTIONS.find((o) => o.id === expiry)!.seconds; + const expiryValid = expiry !== 'custom' || expirySeconds > 0; + + const canShare = + paymentAvailable && !!shareBalance && !balanceError && amountValid && recipientValid && expiryValid && !sending; + + const resetForm = () => { + setAmountInput(''); + setRecipientInput(''); + setRecipientTouched(false); + setExpiry('never'); + setCustomInput(''); + }; const handleShare = async () => { - if (!address || !approvedAddress || creditAmount < 0.01) { - setError('Please fill in all required fields (minimum 0.01 credits)'); - return; - } + if (!canShare) return; setSending(true); setError(''); - + try { const turbo = await createTurboClient(); - const approvedWincAmount = (creditAmount * wincPerCredit).toFixed(); - - await turbo.shareCredits({ - approvedAddress, - approvedWincAmount, - expiresBySeconds: expiresBySeconds > 0 ? expiresBySeconds : undefined, + const approvedWinc = toApprovedWinc(amountInput, shareableWinc); + + const approval = await turbo.shareCredits({ + approvedAddress: recipient, + approvedWincAmount: approvedWinc.toString(), + expiresBySeconds: expirySeconds > 0 ? expirySeconds : undefined, + }); + + // Captured before the form resets and before the balance refetches. + setShared({ + credits: amount, + recipient, + // The service's own expiry when it returns one; otherwise the time + // the share was made, so a re-render can't move it. + endsAt: + expirySeconds > 0 + ? Date.parse(approval?.expirationDate ?? '') || Date.now() + expirySeconds * 1000 + : null, }); - - setSuccess(true); + resetForm(); // Refresh balance in header window.dispatchEvent(new CustomEvent('refresh-balance')); - - // Clear form - setApprovedAddress(''); - setApprovedAddressInput(''); - setRecipientWalletType(null); - setCreditAmount(0); - setCreditAmountInput('0'); - setExpiresBySeconds(0); - - setTimeout(() => { - setSuccess(false); - }, 10000); + refetchBalance(); } catch (err) { - console.error('Share credits error details:', err); - console.error('Error type:', typeof err); - console.error('Error constructor:', err?.constructor?.name); - if (err instanceof Error) { - console.error('Error message:', err.message); - console.error('Error stack:', err.stack); - } - setError(err instanceof Error ? err.message : 'Failed to share credits'); + console.error('Share credits error:', err); + setError(shareFailureMessage(err, 'share')); + // A refusal usually means the balance moved; show the current one. + refetchBalance(); } finally { setSending(false); } }; - const handleRevoke = async () => { - if (!address || !revokeAddress) { - setError('Please enter an address to revoke'); - return; - } - - setRevoking(true); - setError(''); - + const handleRevoke = async (revokedAddress: string) => { try { const turbo = await createTurboClient(); - await turbo.revokeCredits({ revokedAddress: revokeAddress }); - - // Refresh balance in header - window.dispatchEvent(new CustomEvent('refresh-balance')); - - setRevokeAddress(''); + await turbo.revokeCredits({ revokedAddress }); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to revoke credits'); - } finally { - setRevoking(false); + console.error('Revoke credits error:', err); + refetchBalance(); + throw new Error(shareFailureMessage(err, 'revoke')); } + window.dispatchEvent(new CustomEvent('refresh-balance')); + await refetchBalance(); }; if (!address) { @@ -174,376 +264,277 @@ export default function ShareCreditsPanel() { ); } - const spendPower = creditBalance > 0 ? creditBalance.toFixed(4) : '0'; - // For now, we don't have shared credits info without authenticated client - const sharedCredits = '0'; - const givenApprovals: Approval[] = []; + const inputShell = (invalid: boolean) => + `field flex h-12 items-center gap-2 rounded-2xl border bg-background transition-colors ${invalid ? 'border-error' : 'border-border/20 focus-within:border-foreground'}`; - return ( - <div className="px-4 sm:px-6"> - {/* Inline Header with Description */} - <div className="flex items-start gap-3 mb-6"> - <div className="w-10 h-10 bg-primary/20 rounded-2xl flex items-center justify-center flex-shrink-0 mt-1 border border-border/20"> - <Share2 className="w-5 h-5 text-primary" /> - </div> + const balanceHeader = ( + <> + {/* What can be shared, ahead of everything else */} + <div className="flex flex-col gap-2 border-b border-border/20 pb-5 sm:flex-row sm:items-end sm:justify-between"> <div> - <h3 className="text-2xl font-heading font-extrabold text-foreground mb-1">Share Credits</h3> - <p className="text-sm text-foreground/80"> - Delegate credits to other wallets for collaborative uploads and distributed payments - </p> - </div> - </div> - - {/* Main Content Container with Gradient */} - <div className="bg-card rounded-2xl border border-border/20 p-4 sm:p-6 mb-4 sm:mb-6"> - - {/* Current Balance */} - {balance && ( - <div className="bg-card rounded-2xl p-4 mb-6"> - <div className="flex justify-between items-center"> - <span className="text-foreground/80">Available to Share:</span> - <div className="text-right"> - <span className="font-bold text-foreground">{spendPower} Credits</span> - {wincForOneGiB && ( - <div className="text-xs text-foreground/80"> - ~{((creditBalance * wincPerCredit) / Number(wincForOneGiB)).toFixed(2)} GiB capacity - </div> - )} + <div className="text-sm text-foreground/70">You can share</div> + {balanceError ? ( + <div className="mt-1 text-sm text-error"> + Couldn't load your balance.{' '} + <button type="button" onClick={() => refetchBalance()} className="font-medium text-primary hover:underline"> + Try again + </button> </div> - </div> - {+sharedCredits > 0 && ( - <div className="flex justify-between text-sm"> - <span className="text-foreground/80">Already Shared:</span> - <span>{sharedCredits} Credits</span> + ) : ( + <div className="text-3xl font-bold leading-tight tracking-tight"> + {shareBalance ? shareableText : '…'}{' '} + <span className="text-base font-semibold text-foreground/70">credits</span> </div> )} </div> - )} - - {/* Share Form */} - <div className="space-y-6"> - {/* Credits and Recipient on same row */} - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div> - <label className="block text-sm font-medium mb-3">Credits to Share</label> - <input - type="text" - value={creditAmountInput} - onChange={(e) => { - const inputValue = e.target.value; - setCreditAmountInput(inputValue); - - // Parse the numeric value - const amount = Number(inputValue); - if (!isNaN(amount) && amount >= 0) { - setCreditAmount(amount); - } - }} - onBlur={() => { - // Clean up on blur - let finalAmount = creditAmount; - if (creditAmount < 0.01) { - finalAmount = 0.01; - } else if (creditAmount > creditBalance) { - finalAmount = creditBalance; - } - setCreditAmount(finalAmount); - setCreditAmountInput(String(finalAmount)); - }} - className="w-full p-3 rounded-2xl border border-border/20 bg-card text-foreground focus:border-foreground transition-colors" - placeholder="Minimum 0.01 credits" - inputMode="decimal" - /> - {creditAmount >= 0.01 && wincForOneGiB && ( - <div className="mt-2 text-xs text-foreground/80"> - ~{((creditAmount * wincPerCredit) / Number(wincForOneGiB)).toFixed(2)} GiB storage - </div> - )} - {creditAmount > 0 && creditAmount < 0.01 && ( - <div className="mt-2 text-xs text-warning"> - Minimum share amount is 0.01 credits - </div> - )} - {creditAmount > creditBalance && ( - <div className="mt-2 text-xs text-error"> - Exceeds available balance ({creditBalance.toFixed(2)} credits) - </div> - )} + {shareBalance && !balanceError && receivedWinc > 0n && ( + <div className="text-xs leading-relaxed text-foreground/70 sm:text-right sm:text-sm"> + <div>{formatCreditsFloor(shareableWinc + receivedWinc)} spendable in total</div> + <div> + {receivedText} shared with you <span className="text-foreground/60">· spend only</span> + </div> </div> + )} + </div> - <div> - <label className="block text-sm font-medium mb-3">Recipient Wallet Address</label> - <input - type="text" - value={approvedAddressInput} - onChange={(e) => { - setApprovedAddressInput(e.target.value); - // Clear validation state on change - if (approvedAddress) { - setApprovedAddress(''); - setRecipientWalletType(null); - } - }} - onBlur={() => { - const trimmed = approvedAddressInput.trim(); - if (trimmed) { - const validation = validateWalletAddress(trimmed); - if (validation.isValid && validation.type !== 'unknown') { - setApprovedAddress(trimmed); - setRecipientWalletType(validation.type); - } else { - setRecipientWalletType('unknown'); - } - } else { - setApprovedAddress(''); - setRecipientWalletType(null); - } - }} - className="w-full p-3 rounded-2xl border border-border/20 bg-card text-foreground font-mono text-sm focus:border-foreground transition-colors" - placeholder="Arweave, Ethereum, or Solana address" - /> - {recipientWalletType && recipientWalletType !== 'unknown' && ( - <div className="mt-2 text-xs text-success flex items-center gap-1"> - <CheckCircle className="w-3 h-3" /> - Valid {getWalletTypeLabel(recipientWalletType)} address - </div> - )} - {recipientWalletType === 'unknown' && ( - <div className="mt-2 text-xs text-error"> - Invalid wallet address format - </div> - )} - {!recipientWalletType && ( - <div className="mt-2 text-xs text-foreground/80"> - e.g., 1seRanklLU_1VTGkEk7P0x... - </div> - )} - </div> + </> + ); + + const form = ( + <> + {/* Amount */} + <div className="flex flex-col gap-2"> + <label htmlFor="share-amount" className="text-sm font-semibold">Amount</label> + <div className={`${inputShell(amountOver || amountUnder || amountMalformed)} pl-4 pr-1.5`}> + <input + id="share-amount" + type="text" + inputMode="decimal" + autoComplete="off" + value={amountInput} + onChange={(e) => setAmountInput(e.target.value)} + placeholder="0.00" + aria-invalid={amountOver || amountUnder || amountMalformed} + aria-describedby="share-amount-help" + className="min-w-0 flex-1 bg-transparent text-base text-foreground" + /> + <span className="text-sm text-foreground/60">credits</span> + <button + type="button" + onClick={() => setAmountInput(shareableText)} + disabled={!shareBalance || shareableWinc === 0n} + className={buttonClass('secondary', 'xs')} + > + Max + </button> + </div> + <div id="share-amount-help" className="text-xs"> + {amountOver ? ( + <span className="text-error"> + You can share up to {shareableText}. + {receivedWinc > 0n && ` The ${receivedText} shared with you can be spent but not passed on.`} + </span> + ) : amountUnder ? ( + <span className="text-error">The smallest share is {MIN_SHARE} credits.</span> + ) : amountMalformed ? ( + <span className="text-error">Enter a number, like 5 or 0.25.</span> + ) : gib !== null ? ( + <span className="text-foreground/60">About {gib.toFixed(2)} GiB of storage</span> + ) : ( + <span className="text-foreground/60">At least {MIN_SHARE} credits</span> + )} </div> + </div> - <div> - <label className="block text-sm font-medium mb-3">Expiration (Optional)</label> - <div className="grid grid-cols-4 gap-2 mb-3"> - <button - onClick={() => setExpiresBySeconds(3600)} - className={`py-2 px-3 rounded-2xl border text-sm transition-all ${ - expiresBySeconds === 3600 - ? 'border-foreground bg-foreground/10 text-foreground' - : 'border-border/20 text-foreground/80 hover:bg-card hover:text-foreground' - }`} - > - 1 hour - </button> - <button - onClick={() => setExpiresBySeconds(86400)} - className={`py-2 px-3 rounded-2xl border text-sm transition-all ${ - expiresBySeconds === 86400 - ? 'border-foreground bg-foreground/10 text-foreground' - : 'border-border/20 text-foreground/80 hover:bg-card hover:text-foreground' - }`} - > - 1 day - </button> - <button - onClick={() => setExpiresBySeconds(604800)} - className={`py-2 px-3 rounded-2xl border text-sm transition-all ${ - expiresBySeconds === 604800 - ? 'border-foreground bg-foreground/10 text-foreground' - : 'border-border/20 text-foreground/80 hover:bg-card hover:text-foreground' - }`} - > - 1 week - </button> + {/* Recipient */} + <div className="flex flex-col gap-2"> + <label htmlFor="share-recipient" className="text-sm font-semibold">Recipient</label> + <div className={`${inputShell(recipientInvalid || recipientIsSelf)} pl-4 pr-3`}> + <input + id="share-recipient" + type="text" + autoComplete="off" + spellCheck={false} + value={recipientInput} + onChange={(e) => setRecipientInput(e.target.value)} + onBlur={() => setRecipientTouched(true)} + placeholder="Wallet address" + aria-invalid={recipientInvalid || recipientIsSelf} + aria-describedby="share-recipient-help" + className="min-w-0 flex-1 bg-transparent font-mono text-sm text-foreground" + /> + {recipientValid && recipientType && ( + <span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-success/10 px-2.5 py-1 text-xs font-semibold text-success"> + <Check className="h-3 w-3" aria-hidden="true" /> + {getWalletTypeLabel(recipientType)} + </span> + )} + </div> + <div id="share-recipient-help" className="text-xs"> + {recipientIsSelf ? ( + <span className="text-error">That's the wallet you're signed in with. Enter the wallet you want to share with.</span> + ) : recipientInvalid ? ( + <span className="text-error">That isn't an Arweave, Ethereum or Solana address.</span> + ) : ( + <span className="text-foreground/60">Arweave, Ethereum or Solana address.</span> + )} + </div> + </div> + + {/* Expiry */} + <div className="flex flex-col gap-2"> + <div id="share-expiry-label" className="text-sm font-semibold">Expires</div> + <div + role="radiogroup" + aria-labelledby="share-expiry-label" + className="grid h-12 grid-cols-5 gap-0.5 rounded-2xl border border-border/20 bg-background p-1 sm:gap-1" + > + {EXPIRY_OPTIONS.map((o) => ( <button - onClick={() => setExpiresBySeconds(0)} - className={`py-2 px-3 rounded-2xl border text-sm transition-all ${ - expiresBySeconds === 0 - ? 'border-foreground bg-foreground/10 text-foreground' - : 'border-border/20 text-foreground/80 hover:bg-card hover:text-foreground' + key={o.id} + type="button" + role="radio" + aria-checked={expiry === o.id} + onClick={() => setExpiry(o.id)} + className={`rounded-xl text-[13px] transition-colors sm:text-sm ${ + expiry === o.id ? 'bg-lavender font-semibold text-foreground' : 'font-medium text-foreground/80 hover:text-foreground' }`} > - Never + <span className="sm:hidden">{o.short}</span> + <span className="hidden sm:inline">{o.label}</span> </button> - </div> - {expiresBySeconds > 0 && ( + ))} + </div> + {expiry === 'custom' && ( + <div className="flex gap-2"> + <label htmlFor="share-expiry-custom" className="sr-only">Expires after</label> <input + id="share-expiry-custom" type="number" - value={expiresBySeconds} - onChange={(e) => setExpiresBySeconds(Math.max(0, Number(e.target.value)))} - className="w-full p-3 rounded-2xl border border-border/20 bg-card text-foreground focus:border-foreground transition-colors" - placeholder="Custom time in seconds" - min="1" + min="0" + step="any" + inputMode="decimal" + value={customInput} + onChange={(e) => setCustomInput(e.target.value)} + placeholder="3" + className="h-12 min-w-0 flex-1 rounded-2xl border border-border/20 bg-background px-4 text-base text-foreground focus:border-foreground" /> - )} - {expiresBySeconds > 0 && ( - <p className="text-xs text-foreground/80 mt-1"> - Expires in {expiresBySeconds < 3600 - ? `${expiresBySeconds} seconds` - : expiresBySeconds < 86400 - ? `${(expiresBySeconds / 3600).toFixed(1)} hours` - : `${(expiresBySeconds / 86400).toFixed(1)} days` - } - </p> - )} - </div> - - {error && ( - <div className="text-error text-sm p-3 bg-error/10 rounded"> - {error} + <label htmlFor="share-expiry-unit" className="sr-only">Unit</label> + <select + id="share-expiry-unit" + value={customUnit} + onChange={(e) => setCustomUnit(e.target.value as keyof typeof CUSTOM_UNITS)} + className="h-12 rounded-2xl border border-border/20 bg-background px-3 text-sm text-foreground" + > + <option value="hours">hours</option> + <option value="days">days</option> + </select> </div> )} + <div className="text-xs text-foreground/60"> + {expirySeconds > 0 ? `Ends ${formatEnd(expirySeconds)}.` : expiry === 'custom' ? 'Enter how long the share lasts.' : 'Stays open until you revoke it.'} + </div> + </div> - {success && ( - <div className="text-success text-sm p-4 bg-success/10 rounded-2xl border border-success/20"> - <div className="flex items-center gap-2 mb-2"> - <Shield className="w-4 h-4" /> - <span className="font-medium">Credits shared successfully!</span> - </div> - <p className="text-xs text-foreground/80"> - {creditAmount} credits are now available for the recipient to use. Your balance has been updated. - </p> - </div> + {/* What the button will do, in one sentence */} + {amountValid && recipientValid && expiryValid && ( + <div className="rounded-2xl bg-background px-4 py-3.5 text-sm leading-relaxed text-foreground/80"> + <strong className="font-mono text-foreground">{formatWalletAddress(recipient, 4)}</strong> can spend up to{' '} + <strong className="text-foreground"> + {plainCredits(amount)} {creditsWord(amount)} + </strong>{' '} + of yours{expirySeconds > 0 ? `, until ${formatEnd(expirySeconds)}` : ''}. You can revoke the unspent part at any time. + </div> + )} + + {error && <div className="rounded-xl bg-error/10 p-3 text-sm text-error">{error}</div>} + + <button type="button" onClick={handleShare} disabled={!canShare} className={`${buttonClass('primary', 'xl')} w-full`}> + {sending ? ( + <> + <Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" /> + Sharing… + </> + ) : ( + <> + <Share2 className="h-5 w-5" aria-hidden="true" /> + {amountValid ? `Share ${plainCredits(amount)} ${creditsWord(amount)}` : 'Share credits'} + </> )} + </button> + </> + ); - <button - onClick={handleShare} - disabled={sending || !address || creditAmount < 0.01 || creditAmount > creditBalance || !approvedAddress} - className={`${buttonClass('primary', 'xl')} w-full`} - > - {sending ? ( - <> - <ArrowRight className="w-5 h-5 animate-pulse" /> - Sharing Credits... - </> - ) : ( - <> - <Share2 className="w-5 h-5" /> - Share Credits - </> - )} - </button> + const success = shared && ( + <div className="flex flex-col gap-4" role="status"> + <div className="flex items-start gap-3"> + <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-success/10"> + <Check className="h-5 w-5 text-success" aria-hidden="true" /> + </div> + <div> + <h4 className="text-xl"> + Shared {plainCredits(shared.credits)} {creditsWord(shared.credits)} + </h4> + <p className="mt-1 text-sm leading-relaxed text-foreground/80"> + <span className="font-mono">{formatWalletAddress(shared.recipient, 4)}</span> can spend{' '} + {shared.credits === 1 ? 'it' : 'them'} now.{' '} + {shared.endsAt !== null ? `Ends ${formatTime(shared.endsAt)}.` : 'Never expires.'} + </p> + </div> </div> + <div> + <button type="button" onClick={() => setShared(null)} className={buttonClass('primary', 'md')}> + Share with someone else + </button> </div> + </div> + ); - {/* Active Approvals */} - {givenApprovals.length > 0 && ( - <div className="mt-8 pt-8 border-t border-border/20"> - <h4 className="mb-4">Active Approvals</h4> - <div className="space-y-3"> - {givenApprovals.map((approval: Approval) => ( - <div key={approval.approvedAddress} className="bg-card rounded-2xl p-3"> - <div className="flex justify-between items-start"> - <div> - <p className="font-mono text-sm">{approval.approvedAddress}</p> - <p className="text-sm text-foreground/80"> - {(+approval.approvedWincAmount / wincPerCredit).toFixed(4)} Credits - {approval.expirationDate && ( - <span className="ml-2"> - • Expires {new Date(approval.expirationDate).toLocaleDateString()} - </span> - )} - </p> - </div> - <button - onClick={() => setRevokeAddress(approval.approvedAddress)} - className="text-error hover:text-error/80 text-sm" - > - Revoke - </button> - </div> - </div> - ))} - </div> + return ( + <div className="px-4 sm:px-6"> + <div className="flex items-start gap-3 mb-6"> + <div className="w-10 h-10 bg-primary/20 rounded-2xl flex items-center justify-center flex-shrink-0 mt-1 border border-border/20"> + <Share2 className="w-5 h-5 text-primary" /> + </div> + <div> + <h3 className="text-2xl font-heading font-extrabold text-foreground mb-1">Share Credits</h3> + <p className="text-sm text-foreground/80"> + Let another wallet spend some of your credits. You can revoke whatever they haven't spent. + </p> + <DocsLink href="https://docs.ar.io/sdks/turbo-sdk/turbo-credit-sharing/" className="mt-1.5 text-xs"> + How credit sharing works + </DocsLink> + </div> + </div> - {/* Revoke Form */} - {revokeAddress && ( - <div className="mt-4 p-4 bg-card rounded-2xl"> - <p className="text-sm mb-3">Revoke credits from: {revokeAddress}</p> - <div className="flex gap-3"> - <button - onClick={handleRevoke} - disabled={revoking} - className={buttonClass('danger', 'sm')} - > - {revoking ? 'Revoking...' : 'Confirm Revoke'} - </button> - <button - onClick={() => setRevokeAddress('')} - className={buttonClass('secondary', 'sm')} - > - Cancel - </button> - </div> - </div> - )} + {!paymentAvailable && ( + <div className="mb-6 rounded-2xl border border-border/20 bg-card p-4 text-sm text-foreground/80"> + Sharing credits needs the Turbo payment service, which x402-only mode turns off. </div> )} - {/* Resource Links */} - <div className="mt-8"> - <h4 className="mb-4">Learn More</h4> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - {/* Documentation */} - <a - href="https://docs.ardrive.io/docs/turbo/credit-sharing.html" - target="_blank" - rel="noopener noreferrer" - className="bg-card rounded-2xl p-4 hover:bg-card/80 transition-colors group" - > - <div className="flex items-center gap-3 mb-2"> - <Book className="w-5 h-5 text-foreground" /> - <span className="font-medium">Documentation</span> - </div> - <p className="text-xs text-foreground/80"> - Complete guide to credit sharing features and API - </p> - <div className="flex items-center gap-1 mt-2 text-xs text-foreground/80 group-hover:text-foreground"> - <span>Read guide</span> - <ExternalLink className="w-3 h-3" /> - </div> - </a> - - {/* Use Cases */} - <a - href="https://docs.ardrive.io/docs/turbo/credit-sharing.html#use-cases" - target="_blank" - rel="noopener noreferrer" - className="bg-card rounded-2xl p-4 hover:bg-card/80 transition-colors group" - > - <div className="flex items-center gap-3 mb-2"> - <Lightbulb className="w-5 h-5 text-foreground" /> - <span className="font-medium">Use Cases</span> - </div> - <p className="text-xs text-foreground/80"> - Examples and scenarios for credit sharing - </p> - <div className="flex items-center gap-1 mt-2 text-xs text-foreground/80 group-hover:text-foreground"> - <span>View examples</span> - <ExternalLink className="w-3 h-3" /> - </div> - </a> - - {/* SDK Reference */} - <a - href="https://github.com/ardriveapp/turbo-sdk?tab=readme-ov-file#sharecredits-approvedaddress-approvedwincamount-expiresbyseconds-" - target="_blank" - rel="noopener noreferrer" - className="bg-card rounded-2xl p-4 hover:bg-card/80 transition-colors group" - > - <div className="flex items-center gap-3 mb-2"> - <Code className="w-5 h-5 text-foreground" /> - <span className="font-medium">SDK Reference</span> - </div> - <p className="text-xs text-foreground/80"> - Technical implementation details on GitHub - </p> - <div className="flex items-center gap-1 mt-2 text-xs text-foreground/80 group-hover:text-foreground"> - <span>View code</span> - <ExternalLink className="w-3 h-3" /> - </div> - </a> + <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]"> + <section aria-label="New share" className="flex flex-col gap-5 rounded-2xl border border-border/20 bg-card p-4 sm:p-6"> + {balanceHeader} + {success || form} + </section> + + {/* On desktop the list card is pinned to the form card's height and + scrolls inside it, so the columns end together however many shares + there are. */} + <div className="relative lg:min-h-[28rem]"> + {/* Keyed on load so the list picks its opening tab from real data: + a wallet with only received shares should open on that tab. */} + <ShareCreditsList + key={shareBalance ? 'loaded' : 'loading'} + given={shareBalance?.given ?? []} + received={shareBalance?.received ?? []} + sharedOutWinc={shareBalance?.sharedOutWinc ?? 0n} + receivedWinc={shareBalance?.receivedWinc ?? 0n} + onRevoke={handleRevoke} + /> </div> </div> </div> ); -} \ No newline at end of file +} diff --git a/src/components/panels/TryItNowPanel.tsx b/src/components/panels/TryItNowPanel.tsx index d8902bfc..4f013cfb 100644 --- a/src/components/panels/TryItNowPanel.tsx +++ b/src/components/panels/TryItNowPanel.tsx @@ -13,9 +13,7 @@ import { FileVideo, FileAudio, File, - BookOpen, Code, - ArrowRight, RefreshCw, MoreVertical, Copy, @@ -32,6 +30,7 @@ import { getArweaveUrl, resolveEthereumAddress, getTurboBalance } from '../../ut import CopyButton from '../CopyButton'; import ReceiptModal from '../modals/ReceiptModal'; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; // Get appropriate icon for file type (returns component class) function getFileIconClass(type: string) { @@ -343,6 +342,9 @@ export default function TryItNowPanel() { <p className="text-sm text-foreground/80"> Upload a file for free. It will be stored permanently and accessible to anyone with the link. </p> + <DocsLink href="https://docs.ar.io/learn/what-is-ario/" className="mt-1.5 text-xs"> + What is ar.io? + </DocsLink> </div> </div> @@ -728,45 +730,6 @@ export default function TryItNowPanel() { </div> )} - {/* Learn More CTAs */} - <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8"> - <a - href="https://docs.ar.io/learn/" - target="_blank" - rel="noopener noreferrer" - className="group flex items-center gap-4 p-4 bg-card rounded-2xl border border-border/20 hover:border-primary/30 transition-colors" - > - <div className="w-10 h-10 bg-primary/10 rounded-2xl flex items-center justify-center flex-shrink-0"> - <BookOpen className="w-5 h-5 text-primary" /> - </div> - <div className="flex-1"> - <p className="text-sm font-medium text-foreground group-hover:text-foreground"> - What is the ar.io Network? - </p> - <p className="text-xs text-foreground/80">Learn about the permanent cloud - decentralized storage and hosting that lasts forever</p> - </div> - <ArrowRight className="w-4 h-4 text-foreground/80 group-hover:text-foreground group-hover:translate-x-1 transition-all" /> - </a> - - <a - href="https://docs.ar.io/build/turbo-sdk/" - target="_blank" - rel="noopener noreferrer" - className="group flex items-center gap-4 p-4 bg-card rounded-2xl border border-border/20 hover:border-primary/30 transition-colors" - > - <div className="w-10 h-10 bg-primary/10 rounded-2xl flex items-center justify-center flex-shrink-0"> - <Code className="w-5 h-5 text-primary" /> - </div> - <div className="flex-1"> - <p className="text-sm font-medium text-foreground group-hover:text-foreground"> - Build with Turbo SDK - </p> - <p className="text-xs text-foreground/80">Add permanent uploads to your app, or deploy and host it on the permaweb</p> - </div> - <ArrowRight className="w-4 h-4 text-foreground/80 group-hover:text-foreground group-hover:translate-x-1 transition-all" /> - </a> - </div> - {/* Receipt Modal */} {showReceiptModal && ( <ReceiptModal diff --git a/src/features/arns/ArNSBuyPanel.tsx b/src/features/arns/ArNSBuyPanel.tsx index d83bf5f8..c73c5d4a 100644 --- a/src/features/arns/ArNSBuyPanel.tsx +++ b/src/features/arns/ArNSBuyPanel.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { useStore } from '@/store/useStore'; import { Link } from 'react-router-dom'; -import { ArrowLeft, Globe, ExternalLink, Flame, Tag, Settings2 } from 'lucide-react'; +import { ArrowLeft, Globe, Flame, Tag, Settings2 } from 'lucide-react'; import { ArNSNameSearch } from './components/ArNSNameSearch'; import { ArNSPurchaseCard } from './components/ArNSPurchaseCard'; @@ -10,6 +10,7 @@ import { useBuyArNSName } from './hooks/useBuyArNSName'; import { buildTargetOptions, labelForTxId } from './purchase/buyTarget'; import type { BuyTargetState } from './purchase/buyTarget'; import type { BuyArNSNameInput } from './hooks/useBuyArNSName'; +import DocsLink from '@/components/DocsLink'; /** * ArNS "buy a name with Turbo Credits" — the in-console replacement for the old @@ -141,15 +142,9 @@ export function ArNSBuyPanel({ initialSearch }: { initialSearch?: string } = {}) fees are included, so neither needs SOL. You'll approve the purchase once in your wallet, and the name is yours. </p> - <a - href="https://docs.ar.io/learn/arns" - target="_blank" - rel="noopener noreferrer" - className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" - > + <DocsLink href="https://docs.ar.io/learn/arns" className="mt-1.5 text-xs"> What is ArNS? - <ExternalLink className="h-3 w-3" /> - </a> + </DocsLink> </div> </div> )} diff --git a/src/features/arns/components/ArNSCostBreakdown.tsx b/src/features/arns/components/ArNSCostBreakdown.tsx index 165b88d9..a253189b 100644 --- a/src/features/arns/components/ArNSCostBreakdown.tsx +++ b/src/features/arns/components/ArNSCostBreakdown.tsx @@ -12,6 +12,7 @@ import PriceAmount from './PriceAmount'; import { splitNameAndSetup } from '../purchase/priceTotals'; import { OPERATOR_DISCOUNT_PERCENT } from '../purchase/operatorDiscount'; import { useCreditsForFiat } from '../../../hooks/useCreditsForFiat'; +import DocsLink from '@/components/DocsLink'; /** Where to send users who need SOL for account rent and fees. Configurable. */ const GET_SOL_URL = 'https://www.coinbase.com/how-to-buy/solana'; @@ -669,15 +670,12 @@ export function ArNSCostBreakdown({ you pay with ARIO. </p> )} - <a + <DocsLink href="https://docs.ar.io/build/upload/turbo-credits#pricing--fees" - target="_blank" - rel="noopener noreferrer" - className="mt-2 inline-flex items-center gap-0.5 text-xs text-foreground/60 transition-colors hover:text-primary" + className="mt-2 text-xs" > How pricing works - <ExternalLink className="h-3 w-3" /> - </a> + </DocsLink> </> ); } diff --git a/src/features/arns/components/ManageDomainModal.tsx b/src/features/arns/components/ManageDomainModal.tsx index cbdd38b2..54511b5a 100644 --- a/src/features/arns/components/ManageDomainModal.tsx +++ b/src/features/arns/components/ManageDomainModal.tsx @@ -4,7 +4,6 @@ import { CalendarPlus, CheckCircle2, CreditCard, - ExternalLink, Infinity as InfinityIcon, Layers, Loader2, @@ -47,6 +46,7 @@ import ArNSPaymentModal from './ArNSPaymentModal'; import ArNSCardPaymentModal from './ArNSCardPaymentModal'; import ModalHeader from '../../../components/modals/ModalHeader'; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; const LEASE_YEAR_OPTIONS = [1, 2, 3, 4, 5]; const UNDERNAME_QTY_OPTIONS = [1, 5, 10, 25, 50]; @@ -619,15 +619,9 @@ export default function ManageDomainModal({ </button> ))} </div> - <a - href="https://docs.ar.io/learn/arns" - target="_blank" - rel="noopener noreferrer" - className="mt-2 inline-flex items-center gap-1 text-xs text-primary hover:underline" - > - Learn about undernames - <ExternalLink className="h-3 w-3" /> - </a> + <DocsLink href="https://docs.ar.io/learn/arns" className="mt-2 text-xs"> + How undernames work + </DocsLink> </div> )} diff --git a/src/features/arns/components/PrimaryNameModal.tsx b/src/features/arns/components/PrimaryNameModal.tsx index 56a36e6f..06482688 100644 --- a/src/features/arns/components/PrimaryNameModal.tsx +++ b/src/features/arns/components/PrimaryNameModal.tsx @@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom'; import { ArrowDown, CheckCircle2, - ExternalLink, Info, Loader2, Search, @@ -19,6 +18,7 @@ import ModalHeader from '../../../components/modals/ModalHeader'; import NeedsSolNote from './NeedsSolNote'; import TransactionReceipt from './TransactionReceipt'; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; export type PrimaryNameModalMode = 'set' | 'change' | 'approve'; @@ -196,15 +196,7 @@ export default function PrimaryNameModal({ ) : ( <span> Setting a name you own is a single wallet approval.{' '} - <a - href="https://docs.ar.io/learn/arns" - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-1 whitespace-nowrap font-medium text-primary hover:underline" - > - Learn about primary names - <ExternalLink className="h-3 w-3" /> - </a> + <DocsLink href="https://docs.ar.io/learn/arns">How primary names work</DocsLink> </span> )} </div> diff --git a/src/features/browse/components/BrowsePanel.tsx b/src/features/browse/components/BrowsePanel.tsx index 2396f60d..6b7d3431 100644 --- a/src/features/browse/components/BrowsePanel.tsx +++ b/src/features/browse/components/BrowsePanel.tsx @@ -33,6 +33,7 @@ import { } from "../utils/contentTypeUtils"; import type { VerificationEvent } from "../service-worker/types"; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; /** * Extract the base gateway domain from a URL, removing the first subdomain. @@ -1187,14 +1188,9 @@ function BrowsePanelContent({ {!isFullscreen && resolvedUrl && ( <div className="h-5 flex-shrink-0 px-3 flex items-center justify-between"> {browseConfig.verificationEnabled ? ( - <a - href="https://docs.ar.io/learn/wayfinder/" - target="_blank" - rel="noopener noreferrer" - className="text-foreground/40 hover:text-primary text-[11px] transition-colors" - > - Learn about Wayfinder - </a> + <DocsLink href="https://docs.ar.io/learn/wayfinder/" className="text-xs"> + How verification works + </DocsLink> ) : ( <span /> )} diff --git a/src/features/browse/components/VerificationBadge.tsx b/src/features/browse/components/VerificationBadge.tsx index 15db9de6..bc2fea30 100644 --- a/src/features/browse/components/VerificationBadge.tsx +++ b/src/features/browse/components/VerificationBadge.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { LoadingSpinner } from "./LoadingSpinner"; +import DocsLink from '@/components/DocsLink'; export type VerificationState = | "idle" // No verification in progress @@ -318,14 +319,11 @@ export function VerificationBadge({ </button> )} - <a - href="https://docs.ar.io/learn/wayfinder/" - target="_blank" - rel="noopener noreferrer" - className="block mt-2 text-center text-xs text-foreground/50 hover:text-primary transition-colors" - > - Learn more about Wayfinder - </a> + <div className="mt-2 text-center"> + <DocsLink href="https://docs.ar.io/learn/wayfinder/" className="text-xs"> + How verification works + </DocsLink> + </div> </div> )} </div> diff --git a/src/features/browse/components/VerificationBlockedModal.tsx b/src/features/browse/components/VerificationBlockedModal.tsx index 0c665e1e..4e2f0ad1 100644 --- a/src/features/browse/components/VerificationBlockedModal.tsx +++ b/src/features/browse/components/VerificationBlockedModal.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import BaseModal from "@/components/modals/BaseModal"; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; interface VerificationBlockedModalProps { identifier: string; @@ -186,14 +187,9 @@ export function VerificationBlockedModal({ </div> <div className="mt-3 text-center"> - <a - href="https://docs.ar.io/learn/wayfinder/" - target="_blank" - rel="noopener noreferrer" - className="text-xs text-foreground/50 hover:text-primary transition-colors" - > - Learn more about Wayfinder verification - </a> + <DocsLink href="https://docs.ar.io/learn/wayfinder/" className="text-xs"> + How verification works + </DocsLink> </div> </div> </div> diff --git a/src/features/pages/components/PublishModal.tsx b/src/features/pages/components/PublishModal.tsx index 664e9f46..e8ebf970 100644 --- a/src/features/pages/components/PublishModal.tsx +++ b/src/features/pages/components/PublishModal.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { AlertTriangle, ExternalLink, Info, Loader2, Rocket, Sparkles } from 'lucide-react'; +import { AlertTriangle, Info, Loader2, Rocket, Sparkles } from 'lucide-react'; import BaseModal from '@/components/modals/BaseModal'; import { tokenLabels, type SupportedTokenType } from '@/constants'; import { isFileFree, useFreeStatus } from '@/hooks/useFreeUploadLimit'; @@ -12,6 +12,7 @@ import { arnsLabel } from '../publish/permalink'; import { MAX_PAGE_BYTES, estimatePageCredits } from '../publish/cost'; import type { PublishStage } from '../hooks/usePagePublish'; import { buttonClass } from '@/components/button'; +import DocsLink from '@/components/DocsLink'; interface PublishModalProps { def: PageDef; @@ -217,15 +218,9 @@ export default function PublishModal({ <span className="text-xs text-foreground/70">Your balance</span> <span className="text-sm text-foreground">{creditBalance.toFixed(6)} Credits</span> </div> - <a - href="https://docs.ar.io/build/upload/turbo-credits" - target="_blank" - rel="noopener noreferrer" - className="mt-1.5 inline-flex items-center gap-1 text-xs text-foreground/45 transition-colors hover:text-foreground/70" - > + <DocsLink href="https://docs.ar.io/build/upload/turbo-credits" className="mt-1.5 text-xs"> What are credits? - <ExternalLink className="h-3 w-3" /> - </a> + </DocsLink> {!hasCredits && !canJit && ( <div className="mt-3 flex items-start gap-2 rounded-lg border border-error/20 bg-error/10 p-3"> <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-error" /> diff --git a/src/features/pages/components/controls/DomainControls.tsx b/src/features/pages/components/controls/DomainControls.tsx index 7f0987eb..faf0d447 100644 --- a/src/features/pages/components/controls/DomainControls.tsx +++ b/src/features/pages/components/controls/DomainControls.tsx @@ -1,5 +1,6 @@ -import { ExternalLink, Info } from 'lucide-react'; +import { Info } from 'lucide-react'; import ArNSAssociationPanel from '@/components/ArNSAssociationPanel'; +import DocsLink from '@/components/DocsLink'; interface DomainControlsProps { enabled: boolean; @@ -38,15 +39,7 @@ export default function DomainControls({ <Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-primary" /> <span> Optional — add a domain now, or anytime after you publish.{' '} - <a - href="https://docs.ar.io/learn/arns" - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-1 whitespace-nowrap font-medium text-primary transition-colors hover:text-primary/80" - > - How ArNS works - <ExternalLink className="h-3 w-3" /> - </a> + <DocsLink href="https://docs.ar.io/learn/arns">How ArNS works</DocsLink> </span> </div> <ArNSAssociationPanel diff --git a/src/utils/shareableCredits.test.ts b/src/utils/shareableCredits.test.ts new file mode 100644 index 00000000..78534a73 --- /dev/null +++ b/src/utils/shareableCredits.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { toShareableBalance, toApprovedWinc, wincToCredits, toShareRows, isSameWallet, formatCreditsFloor, parseCreditsToWinc, shareFailureMessage } from './shareableCredits'; + +const CREDIT = 1_000_000_000_000n; + +describe('toShareableBalance', () => { + it('excludes credits shared with the wallet from what it can share', () => { + // The reported case: ~33.9 owned + 3.55 received shows as 37.46 spendable. + const owned = 33_912_200_000_000n; + const received = 3_550_000_000_000n; + const b = toShareableBalance({ + winc: String(owned), + controlledWinc: String(owned), + effectiveBalance: String(owned + received), + }); + expect(b.shareableWinc).toBe(owned); + expect(b.receivedWinc).toBe(received); + expect(b.sharedOutWinc).toBe(0n); + }); + + it('reports credits already shared out from controlledWinc', () => { + const b = toShareableBalance({ + winc: String(8n * CREDIT), + controlledWinc: String(10n * CREDIT), + effectiveBalance: String(8n * CREDIT), + }); + expect(b.shareableWinc).toBe(8n * CREDIT); + expect(b.sharedOutWinc).toBe(2n * CREDIT); + expect(b.receivedWinc).toBe(0n); + }); + + it('reads missing fields as zero (the 404 shape is { winc: 0 })', () => { + expect(toShareableBalance({ winc: 0 })).toEqual({ + shareableWinc: 0n, + receivedWinc: 0n, + sharedOutWinc: 0n, + }); + expect(toShareableBalance(null).shareableWinc).toBe(0n); + }); + + it('ignores malformed values rather than throwing', () => { + const b = toShareableBalance({ winc: 'abc', effectiveBalance: '-5', controlledWinc: undefined }); + expect(b).toEqual({ shareableWinc: 0n, receivedWinc: 0n, sharedOutWinc: 0n }); + }); + + it('keeps precision beyond Number.MAX_SAFE_INTEGER', () => { + const big = '123456789012345678901'; + expect(toShareableBalance({ winc: big }).shareableWinc).toBe(BigInt(big)); + }); +}); + +describe('toApprovedWinc', () => { + it('converts credits to winc', () => { + expect(toApprovedWinc(1.5, 10n * CREDIT)).toBe(1_500_000_000_000n); + }); + + it('never exceeds the shareable balance, so "share everything" succeeds', () => { + const shareable = 33_912_345_678_901n; + expect(toApprovedWinc(wincToCredits(shareable), shareable)).toBeLessThanOrEqual(shareable); + expect(toApprovedWinc(1000, shareable)).toBe(shareable); + }); + + it('returns zero for non-positive or non-finite amounts', () => { + expect(toApprovedWinc(0, CREDIT)).toBe(0n); + expect(toApprovedWinc(-1, CREDIT)).toBe(0n); + expect(toApprovedWinc(NaN, CREDIT)).toBe(0n); + }); +}); + +describe('toShareRows', () => { + const now = Date.parse('2026-10-20T00:00:00Z'); + + it('groups given approvals by recipient, because revoke works per address', () => { + const rows = toShareRows( + [ + { approvedAddress: 'A', payingAddress: 'me', approvedWincAmount: String(2n * CREDIT), usedWincAmount: String(CREDIT / 2n), creationDate: '2026-10-01T00:00:00Z' }, + { approvedAddress: 'A', payingAddress: 'me', approvedWincAmount: String(CREDIT), usedWincAmount: '0', creationDate: '2026-10-05T00:00:00Z', expirationDate: '2026-10-27T00:00:00Z' }, + { approvedAddress: 'B', payingAddress: 'me', approvedWincAmount: String(CREDIT), usedWincAmount: '0', creationDate: '2026-10-03T00:00:00Z' }, + ], + 'given', + now, + ); + expect(rows.map((r) => r.address)).toEqual(['A', 'B']); + expect(rows[0]).toEqual({ + address: 'A', + approvedWinc: 3n * CREDIT, + usedWinc: CREDIT / 2n, + remainingWinc: 3n * CREDIT - CREDIT / 2n, + soonestExpiry: '2026-10-27T00:00:00Z', + count: 2, + }); + expect(rows[1].soonestExpiry).toBeUndefined(); + }); + + it('keys received approvals by the granting wallet', () => { + const rows = toShareRows( + [{ approvedAddress: 'me', payingAddress: 'G', approvedWincAmount: '5', usedWincAmount: '2' }], + 'received', + now, + ); + expect(rows).toEqual([{ address: 'G', approvedWinc: 5n, usedWinc: 2n, remainingWinc: 3n, count: 1 }]); + }); + + it('drops expired approvals and malformed rows', () => { + const rows = toShareRows( + [ + { approvedAddress: 'A', approvedWincAmount: '5', expirationDate: '2026-10-19T00:00:00Z' }, + { approvedWincAmount: '5' }, + { approvedAddress: 'B', approvedWincAmount: 'x', usedWincAmount: '9' }, + ], + 'given', + now, + ); + expect(rows).toEqual([{ address: 'B', approvedWinc: 0n, usedWinc: 9n, remainingWinc: 0n, count: 1 }]); + expect(toShareRows(undefined, 'given', now)).toEqual([]); + }); +}); + +describe('isSameWallet', () => { + it('matches 0x addresses case-insensitively and others exactly', () => { + expect(isSameWallet('0xABCdef0000000000000000000000000000000001', '0xabcdef0000000000000000000000000000000001')).toBe(true); + expect(isSameWallet(' HtR3kP9w ', 'HtR3kP9w')).toBe(true); + expect(isSameWallet('htr3kp9w', 'HtR3kP9w')).toBe(false); + expect(isSameWallet('A', null)).toBe(false); + }); +}); + +describe('formatCreditsFloor', () => { + it('rounds down to four places without floating-point drift', () => { + expect(formatCreditsFloor(37_462_200_000_000n)).toBe('37.4622'); + expect(formatCreditsFloor(33_912_299_999_999n)).toBe('33.9122'); + expect(formatCreditsFloor(50_000_000n)).toBe('0.0000'); + expect(formatCreditsFloor(0n)).toBe('0.0000'); + }); + + it('what Max fills in reads back as no more than the balance', () => { + for (const winc of [33_912_200_000_000n, 37_462_200_000_000n, 123_456_789n, 9_007_199_254_740_993_000n]) { + expect(parseCreditsToWinc(formatCreditsFloor(winc))!).toBeLessThanOrEqual(winc); + } + }); +}); + +describe('parseCreditsToWinc', () => { + it('parses exactly, without floating point', () => { + expect(parseCreditsToWinc('5')).toBe(5n * CREDIT); + expect(parseCreditsToWinc('0.25')).toBe(CREDIT / 4n); + expect(parseCreditsToWinc('.5')).toBe(CREDIT / 2n); + expect(parseCreditsToWinc('3.')).toBe(3n * CREDIT); + expect(parseCreditsToWinc(' 12345.6789 ')).toBe(12_345_678_900_000_000n); + // Number('9007.1993') * 1e12 lands a winc away from what was typed. + expect(parseCreditsToWinc('9007.1993')).toBe(9_007_199_300_000_000n); + }); + + it('rounds digits past the twelfth place half up', () => { + expect(parseCreditsToWinc('0.0000000000014')).toBe(1n); + expect(parseCreditsToWinc('0.0000000000015')).toBe(2n); + }); + + it('rejects anything that is not a plain decimal', () => { + for (const bad of ['', '.', '-1', '1e3', 'abc', '1.2.3', '0x10']) { + expect(parseCreditsToWinc(bad)).toBeUndefined(); + } + }); + + it('toApprovedWinc uses the exact parse for text and still clamps', () => { + expect(toApprovedWinc('9007.1993', 10_000n * CREDIT)).toBe(9_007_199_300_000_000n); + expect(toApprovedWinc('40', 33n * CREDIT)).toBe(33n * CREDIT); + expect(toApprovedWinc('', CREDIT)).toBe(0n); + }); +}); + +describe('shareFailureMessage', () => { + it('turns the SDK wrapper into a refusal the user can act on', () => { + const err = new Error('Failed to upload file after 1 attempts Failed request (Status 400): Insufficient balance'); + expect(shareFailureMessage(err, 'share')).toMatch(/^Couldn't share credits: the payment service says this wallet doesn't have enough credits/); + }); + + it('says nothing happened when the signature was cancelled', () => { + expect(shareFailureMessage(new Error('User rejected the request'), 'revoke')).toBe( + 'Nothing was revoked: the signature request was cancelled.', + ); + }); + + it('strips the wrapper and keeps the service reason', () => { + const err = new Error('Failed to upload file after 3 attempts Failed request (Status 503): Service Unavailable'); + expect(shareFailureMessage(err, 'share')).toBe("Couldn't share credits: Service Unavailable"); + expect(shareFailureMessage(undefined, 'revoke')).toBe("Couldn't revoke that share. Try again."); + }); +}); diff --git a/src/utils/shareableCredits.ts b/src/utils/shareableCredits.ts new file mode 100644 index 00000000..02e68cc8 --- /dev/null +++ b/src/utils/shareableCredits.ts @@ -0,0 +1,208 @@ +import { wincPerCredit } from '../constants'; + +/** + * What a wallet can share, as opposed to what it can spend. + * + * The payment service reports two balances, and the app's global + * `creditBalance` is the larger one: `effectiveBalance`, which adds credits + * other wallets shared with this one so uploads can draw on them. Those + * received credits can be spent but not re-shared, so a share page reading + * `creditBalance` offers more than the service will approve. `winc` is the + * figure turbo-sdk documents as what a user "can currently spend or share". + */ +export interface ShareableBalance { + /** Owned winc the wallet can share now. */ + shareableWinc: bigint; + /** Winc remaining on approvals other wallets gave this one: spendable, not shareable. */ + receivedWinc: bigint; + /** Winc this wallet has approved to others and not yet had spent or revoked. */ + sharedOutWinc: bigint; +} + +function toWinc(value: unknown): bigint { + if (typeof value === 'bigint') return value > 0n ? value : 0n; + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? BigInt(Math.floor(value)) : 0n; + } + if (typeof value === 'string' && /^\d+$/.test(value.trim())) return BigInt(value.trim()); + return 0n; +} + +/** + * Splits a `/v1/account/balance` response into what can be shared and what + * can't. Missing fields read as zero, which is what an account that has never + * shared or received credits returns (and what `getTurboBalance` returns on a + * 404). + */ +export function toShareableBalance(balance: { + winc?: unknown; + controlledWinc?: unknown; + effectiveBalance?: unknown; +} | null | undefined): ShareableBalance { + const winc = toWinc(balance?.winc); + const controlled = toWinc(balance?.controlledWinc); + const effective = toWinc(balance?.effectiveBalance); + return { + shareableWinc: winc, + receivedWinc: effective > winc ? effective - winc : 0n, + sharedOutWinc: controlled > winc ? controlled - winc : 0n, + }; +} + +/** + * For display and form input only. `Number` loses precision above + * Number.MAX_SAFE_INTEGER winc (about 9,007 credits), so never decide an + * approval from this; `toApprovedWinc` compares as bigint. + */ +export function wincToCredits(winc: bigint): number { + return Number(winc) / wincPerCredit; +} + +const WINC_DECIMALS = String(wincPerCredit).length - 1; // 12 + +/** + * Credits as typed ("5", "0.25", ".5", "3."), in winc, parsed as a decimal + * string so nothing passes through floating point: `Number` turns some + * four-place figures above ~9,000 credits into a winc more than they say. + * Digits past the twelfth place round half up. Undefined for anything else. + */ +export function parseCreditsToWinc(text: string): bigint | undefined { + const m = /^(\d*)(?:\.(\d*))?$/.exec(text.trim()); + if (!m || (m[1] === '' && !m[2])) return undefined; + const frac = m[2] ?? ''; + const kept = frac.slice(0, WINC_DECIMALS).padEnd(WINC_DECIMALS, '0'); + const roundUp = frac.length > WINC_DECIMALS && frac[WINC_DECIMALS] >= '5' ? 1n : 0n; + return BigInt(m[1] || '0') * BigInt(wincPerCredit) + BigInt(kept) + roundUp; +} + +/** A credits figure that is already a number (not typed), in winc. */ +function numberToWinc(credits: number): bigint | undefined { + if (!Number.isFinite(credits) || credits < 0) return undefined; + return BigInt(Math.round(credits * wincPerCredit)); +} + +const WINC_PER_TEN_THOUSANDTH = BigInt(wincPerCredit) / 10_000n; + +/** + * Winc as credits to four places, rounded DOWN, worked in bigint. Rounding + * down means a figure the user copies into the form (or that Max fills in) + * never exceeds the balance; doing it in floating point turned 37.4622 into + * 37.4621. + */ +export function formatCreditsFloor(winc: bigint): string { + const units = (winc > 0n ? winc : 0n) / WINC_PER_TEN_THOUSANDTH; + return `${units / 10_000n}.${(units % 10_000n).toString().padStart(4, '0')}`; +} + +/** + * The winc to approve for a share of `credits`, never above `shareableWinc`. + * Pass the form's text where there is one (`parseCreditsToWinc`, exact). + * + * The clamp is a backstop: a number can round-trip through floating point and + * land a winc or two above the balance, which the service refuses. Clamping + * here makes "share everything" always succeed. + */ +export function toApprovedWinc(credits: number | string, shareableWinc: bigint): bigint { + const requested = (typeof credits === 'string' ? parseCreditsToWinc(credits) : numberToWinc(credits)) ?? 0n; + return requested > shareableWinc ? shareableWinc : requested; +} + +/** One approval as `/v1/account/balance` lists it (turbo-sdk `CreditShareApproval`). */ +export interface ApprovalLike { + approvedAddress?: string; + payingAddress?: string; + approvedWincAmount?: unknown; + usedWincAmount?: unknown; + creationDate?: string; + expirationDate?: string; +} + +/** Every approval between this wallet and one other wallet, in one direction. */ +export interface ShareRow { + /** The other wallet: the recipient of a given share, the granter of a received one. */ + address: string; + approvedWinc: bigint; + usedWinc: bigint; + remainingWinc: bigint; + /** The soonest expiry among these approvals; undefined when none expire. */ + soonestExpiry?: string; + /** How many approvals the row stands for. */ + count: number; +} + +const time = (iso?: string) => { + const t = iso ? Date.parse(iso) : NaN; + return Number.isFinite(t) ? t : undefined; +}; + +/** + * Groups approvals by the other wallet, newest first, dropping any that have + * expired. + * + * Grouping is not cosmetic for given shares: `revokeCredits` takes an address + * and revokes every approval to it, so a Revoke button on a single approval + * would revoke more than its row shows. + */ +export function toShareRows( + approvals: ApprovalLike[] | null | undefined, + direction: 'given' | 'received', + now: number = Date.now(), +): ShareRow[] { + const rows = new Map<string, ShareRow>(); + const newest = new Map<string, number>(); + for (const a of approvals ?? []) { + const address = direction === 'given' ? a.approvedAddress : a.payingAddress; + if (!address) continue; + const expires = time(a.expirationDate); + if (expires !== undefined && expires <= now) continue; + + const approved = toWinc(a.approvedWincAmount); + const used = toWinc(a.usedWincAmount); + const row = rows.get(address) ?? { + address, + approvedWinc: 0n, + usedWinc: 0n, + remainingWinc: 0n, + count: 0, + }; + row.approvedWinc += approved; + row.usedWinc += used; + row.remainingWinc += approved > used ? approved - used : 0n; + row.count += 1; + newest.set(address, Math.max(newest.get(address) ?? 0, time(a.creationDate) ?? 0)); + if (expires !== undefined && (row.soonestExpiry === undefined || expires < time(row.soonestExpiry)!)) { + row.soonestExpiry = a.expirationDate; + } + rows.set(address, row); + } + return [...rows.values()].sort((x, y) => newest.get(y.address)! - newest.get(x.address)!); +} + +/** Whether `candidate` is `own`, ignoring the case of a 0x address. */ +export function isSameWallet(candidate: string, own: string | null | undefined): boolean { + if (!own) return false; + const a = candidate.trim(); + return /^0x/i.test(a) ? a.toLowerCase() === own.toLowerCase() : a === own; +} + +/** + * A share or revoke failure in words a person can act on. Both go through + * turbo-sdk's `uploadFile`, so a service refusal arrives wrapped as "Failed to + * upload file after N attempts Failed request (Status 400): <reason>", which + * reads like a crash rather than a refusal. + */ +export function shareFailureMessage(err: unknown, action: 'share' | 'revoke'): string { + const raw = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + const verb = action === 'share' ? 'share credits' : 'revoke that share'; + if (/insufficient balance/i.test(raw)) { + return `Couldn't ${verb}: the payment service says this wallet doesn't have enough credits for it. Your balance may have changed; it has been refreshed.`; + } + if (/user (rejected|denied|cancel)|cancelled|canceled|rejected the request/i.test(raw)) { + return `Nothing was ${action === 'share' ? 'shared' : 'revoked'}: the signature request was cancelled.`; + } + const reason = raw + .replace(/^Failed to upload file after \d+ attempts?\s*/i, '') + .replace(/^Failed request \(Status (\d+)\):\s*/i, '') + .trim(); + return reason ? `Couldn't ${verb}: ${reason}` : `Couldn't ${verb}. Try again.`; +}