From 21c38bec1b23fa7b9122f559cfc153ef8cb53f1c Mon Sep 17 00:00:00 2001 From: Jemimah Yero Date: Tue, 28 Jul 2026 18:49:46 +0100 Subject: [PATCH 1/4] feat: transaction hash display with truncation, copy, and expand (#489) --- src/components/ui/TxHash.tsx | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/components/ui/TxHash.tsx diff --git a/src/components/ui/TxHash.tsx b/src/components/ui/TxHash.tsx new file mode 100644 index 0000000..1c55606 --- /dev/null +++ b/src/components/ui/TxHash.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; +import CopyButton from '@/components/CopyButton'; + +interface TxHashProps { + hash: string; + network?: 'testnet' | 'mainnet'; +} + +export default function TxHash({ hash, network = 'testnet' }: TxHashProps) { + const [expanded, setExpanded] = useState(false); + + const explorerUrl = + network === 'mainnet' + ? `https://stellar.expert/explorer/public/tx/${hash}` + : `https://stellar.expert/explorer/testnet/tx/${hash}`; + + if (expanded) { + return ( +
+ + {hash} + +
+ + + + + Explorer + +
+
+ ); + } + + const truncated = `${hash.slice(0, 8)}...${hash.slice(-6)}`; + + return ( +
+ + {truncated} + + + +
+ ); +} From 3d2d7def81e3a4393f162352c72eec2f259da25b Mon Sep 17 00:00:00 2001 From: Jemimah Yero Date: Tue, 28 Jul 2026 18:49:50 +0100 Subject: [PATCH 2/4] feat: recipient count limit enforcement UI (#488) --- src/components/SplitCalculator.tsx | 12 ++++++++++-- src/hooks/useSplitCalculator.ts | 5 +++++ src/lib/stellar.ts | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/SplitCalculator.tsx b/src/components/SplitCalculator.tsx index e7aff10..d4e7f8f 100644 --- a/src/components/SplitCalculator.tsx +++ b/src/components/SplitCalculator.tsx @@ -9,6 +9,7 @@ import { } from "@/hooks/useSplitCalculator"; import { moveItem } from "@/lib/reorder"; import { getEmailForAddress } from "@/lib/addressBook"; +import { MAX_RECIPIENTS } from "@/lib/stellar"; import Avatar from "@/components/ui/Avatar"; import RangeSlider from "@/components/ui/RangeSlider"; import SplitSumIndicator from "@/components/invoice/SplitSumIndicator"; @@ -63,7 +64,7 @@ export default function SplitCalculator({ }, [totalAmount]); const result = useSplitCalculator(parsedTotal, recipients, assetCode); - const { derivedLines, totalGross, totalTax, totalFees, totalNet, validation } = result; + const { derivedLines, totalGross, totalTax, totalFees, totalNet, validation, canAddRecipient, recipientCount } = result; useEffect(() => { if (!onSplitMetaChange) return; @@ -230,7 +231,9 @@ export default function SplitCalculator({ @@ -518,6 +521,11 @@ export default function SplitCalculator({ ))} + {recipientCount > 0 && ( +
+ {recipientCount} / {MAX_RECIPIENTS} recipients +
+ )} {parsedTotal > 0 && derivedLines.length > 0 && ( diff --git a/src/hooks/useSplitCalculator.ts b/src/hooks/useSplitCalculator.ts index f04082b..93956ec 100644 --- a/src/hooks/useSplitCalculator.ts +++ b/src/hooks/useSplitCalculator.ts @@ -1,6 +1,7 @@ 'use client'; import { useMemo } from 'react'; +import { MAX_RECIPIENTS } from '@/lib/stellar'; export interface RecipientLine { address: string; @@ -41,6 +42,8 @@ export interface SplitCalculatorResult { totalFees: number; totalNet: number; validation: SplitValidation; + canAddRecipient: boolean; + recipientCount: number; } const STROOP_SCALE = 1e7; @@ -136,6 +139,8 @@ export function calculateSplit( totalFees, totalNet, validation, + canAddRecipient: recipients.length < MAX_RECIPIENTS, + recipientCount: recipients.length, }; } diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 0a8b695..15a969e 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -18,6 +18,8 @@ const HORIZON_URL = export const USDC_CONTRACT_ID = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? ""; +export const MAX_RECIPIENTS = 10; + export function getSplitClient(): StellarSplitClient { if (!_client) { const contractId = process.env.NEXT_PUBLIC_CONTRACT_ID ?? ""; From 8149b9f0e208e65d61b8eb3c33f176945806631c Mon Sep 17 00:00:00 2001 From: Jemimah Yero Date: Tue, 28 Jul 2026 18:49:54 +0100 Subject: [PATCH 3/4] feat: wallet disconnect confirmation modal (#487) --- src/components/wallet/DisconnectModal.tsx | 93 ++++++++++++++++++++ src/components/wallet/WalletMenu.tsx | 100 ++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/components/wallet/DisconnectModal.tsx create mode 100644 src/components/wallet/WalletMenu.tsx diff --git a/src/components/wallet/DisconnectModal.tsx b/src/components/wallet/DisconnectModal.tsx new file mode 100644 index 0000000..cb5672c --- /dev/null +++ b/src/components/wallet/DisconnectModal.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; +import Modal from '@/components/ui/Modal'; + +interface DisconnectModalProps { + open: boolean; + onClose: () => void; + onConfirm: () => Promise; + walletAddress?: string; +} + +export default function DisconnectModal({ + open, + onClose, + onConfirm, + walletAddress, +}: DisconnectModalProps) { + const [confirming, setConfirming] = useState(false); + + const handleConfirm = async () => { + setConfirming(true); + try { + await onConfirm(); + } finally { + setConfirming(false); + } + }; + + const truncatedAddress = walletAddress + ? `${walletAddress.slice(0, 8)}...${walletAddress.slice(-6)}` + : ''; + + return ( + +
+
+

+ Are you sure you want to disconnect your wallet? +

+ {truncatedAddress && ( +

+ Address: {truncatedAddress} +

+ )} +

+ You'll need to reconnect to continue using the app. +

+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/wallet/WalletMenu.tsx b/src/components/wallet/WalletMenu.tsx new file mode 100644 index 0000000..6cc8b2f --- /dev/null +++ b/src/components/wallet/WalletMenu.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useWallet } from '@/hooks/useWallet'; +import DisconnectModal from '@/components/wallet/DisconnectModal'; + +export default function WalletMenu() { + const router = useRouter(); + const { address, disconnect } = useWallet(); + const [open, setOpen] = useState(false); + const [showDisconnectModal, setShowDisconnectModal] = useState(false); + + if (!address) return null; + + const truncatedAddress = `${address.slice(0, 8)}...${address.slice(-6)}`; + + const handleDisconnect = async () => { + await disconnect(); + setShowDisconnectModal(false); + router.push('/'); + }; + + return ( + <> +
+ + + {open && ( + <> +
setOpen(false)} + aria-hidden="true" + /> +
+ +
+ +
+ + )} +
+ + setShowDisconnectModal(false)} + onConfirm={handleDisconnect} + walletAddress={address} + /> + + ); +} From 330127692a1dc3739bc41e826501a85b2fba201d Mon Sep 17 00:00:00 2001 From: Jemimah Yero Date: Tue, 28 Jul 2026 18:49:58 +0100 Subject: [PATCH 4/4] feat: invoice expiry date-time picker with timezone support (#486) --- src/components/invoice/ExpiryDatePicker.tsx | 99 +++++++++++++++++++++ src/components/invoice/ExpiryDisplay.tsx | 34 +++++++ src/hooks/useInvoiceForm.ts | 67 ++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/components/invoice/ExpiryDatePicker.tsx create mode 100644 src/components/invoice/ExpiryDisplay.tsx create mode 100644 src/hooks/useInvoiceForm.ts diff --git a/src/components/invoice/ExpiryDatePicker.tsx b/src/components/invoice/ExpiryDatePicker.tsx new file mode 100644 index 0000000..d739f14 --- /dev/null +++ b/src/components/invoice/ExpiryDatePicker.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface ExpiryDatePickerProps { + value: string; + onChange: (iso: string) => void; + onTimezoneChange?: (tz: string) => void; + timezone?: string; + error?: string; +} + +export default function ExpiryDatePicker({ + value, + onChange, + onTimezoneChange, + timezone: externalTimezone, + error, +}: ExpiryDatePickerProps) { + const [timezone, setTimezone] = useState(() => { + if (externalTimezone) return externalTimezone; + return Intl.DateTimeFormat().resolvedOptions().timeZone; + }); + + const [timezones, setTimezones] = useState([]); + + useEffect(() => { + try { + const supported = Intl.supportedValuesOf('timeZone') as string[]; + setTimezones(supported); + } catch { + setTimezones([]); + } + }, []); + + useEffect(() => { + if (externalTimezone) { + setTimezone(externalTimezone); + } + }, [externalTimezone]); + + const handleTimezoneChange = (tz: string) => { + setTimezone(tz); + if (onTimezoneChange) { + onTimezoneChange(tz); + } + }; + + const isExpired = value && new Date(value) < new Date(); + + return ( +
+
+
+ + onChange(e.target.value)} + aria-invalid={isExpired || !!error} + aria-describedby={error ? 'expiry-error' : undefined} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 aria-invalid:border-red-600 aria-invalid:ring-red-600" + /> +
+ +
+ + +
+
+ + {(error || isExpired) && ( + + )} +
+ ); +} diff --git a/src/components/invoice/ExpiryDisplay.tsx b/src/components/invoice/ExpiryDisplay.tsx new file mode 100644 index 0000000..f721492 --- /dev/null +++ b/src/components/invoice/ExpiryDisplay.tsx @@ -0,0 +1,34 @@ +'use client'; + +interface ExpiryDisplayProps { + timestamp: number; + overrideTimezone?: string; +} + +export default function ExpiryDisplay({ + timestamp, + overrideTimezone, +}: ExpiryDisplayProps) { + const date = new Date(timestamp * 1000); + const timezone = overrideTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + + const formatted = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: timezone, + timeZoneName: 'short', + }).format(date); + + const isExpired = date < new Date(); + + return ( +
+ {formatted} + {isExpired && (Expired)} +
+ ); +} diff --git a/src/hooks/useInvoiceForm.ts b/src/hooks/useInvoiceForm.ts new file mode 100644 index 0000000..e4518f5 --- /dev/null +++ b/src/hooks/useInvoiceForm.ts @@ -0,0 +1,67 @@ +'use client'; + +import { useCallback, useState, useMemo } from 'react'; + +interface InvoiceFormState { + expiryDate: string; + timezone: string; +} + +export function useInvoiceForm() { + const [state, setState] = useState(() => ({ + expiryDate: '', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + })); + + const setExpiryDate = useCallback((date: string) => { + setState((prev) => ({ ...prev, expiryDate: date })); + }, []); + + const setTimezone = useCallback((tz: string) => { + setState((prev) => ({ ...prev, timezone: tz })); + }, []); + + const validation = useMemo(() => { + const errors: Record = {}; + + if (state.expiryDate) { + const date = new Date(state.expiryDate); + if (date < new Date()) { + errors.expiryDate = 'Expiry date cannot be in the past'; + } + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; + }, [state.expiryDate]); + + const getUtcTimestamp = useCallback((): number | null => { + if (!state.expiryDate || !validation.isValid) { + return null; + } + + const localDate = new Date(state.expiryDate); + return Math.floor(localDate.getTime() / 1000); + }, [state.expiryDate, validation.isValid]); + + const convertToUtcIso = useCallback((): string | null => { + if (!state.expiryDate) { + return null; + } + + const localDate = new Date(state.expiryDate); + return localDate.toISOString(); + }, [state.expiryDate]); + + return { + expiryDate: state.expiryDate, + timezone: state.timezone, + setExpiryDate, + setTimezone, + validation, + getUtcTimestamp, + convertToUtcIso, + }; +}