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/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) && (
+
+ {error || 'Expiry date cannot be in the past'}
+
+ )}
+
+ );
+}
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/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}
+
+
+
+
+ );
+}
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}
+ />
+ >
+ );
+}
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,
+ };
+}
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 ?? "";