Skip to content

Commit 2bae61b

Browse files
authored
feat: implement features #486-489 (expiry picker, wallet disconnect, recipient limit, tx hash) (#538)
* feat: transaction hash display with truncation, copy, and expand (#489) * feat: recipient count limit enforcement UI (#488) * feat: wallet disconnect confirmation modal (#487) * feat: invoice expiry date-time picker with timezone support (#486)
1 parent 9bb6862 commit 2bae61b

9 files changed

Lines changed: 477 additions & 2 deletions

File tree

src/components/SplitCalculator.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@/hooks/useSplitCalculator";
1010
import { moveItem } from "@/lib/reorder";
1111
import { getEmailForAddress } from "@/lib/addressBook";
12+
import { MAX_RECIPIENTS } from "@/lib/stellar";
1213
import Avatar from "@/components/ui/Avatar";
1314
import RangeSlider from "@/components/ui/RangeSlider";
1415
import SplitSumIndicator from "@/components/invoice/SplitSumIndicator";
@@ -63,7 +64,7 @@ export default function SplitCalculator({
6364
}, [totalAmount]);
6465

6566
const result = useSplitCalculator(parsedTotal, recipients, assetCode);
66-
const { derivedLines, totalGross, totalTax, totalFees, totalNet, validation } = result;
67+
const { derivedLines, totalGross, totalTax, totalFees, totalNet, validation, canAddRecipient, recipientCount } = result;
6768

6869
useEffect(() => {
6970
if (!onSplitMetaChange) return;
@@ -230,7 +231,9 @@ export default function SplitCalculator({
230231
<button
231232
type="button"
232233
onClick={addRecipient}
233-
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors"
234+
disabled={!canAddRecipient}
235+
title={!canAddRecipient ? `Maximum ${MAX_RECIPIENTS} recipients reached` : undefined}
236+
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600"
234237
>
235238
+ Add Line
236239
</button>
@@ -518,6 +521,11 @@ export default function SplitCalculator({
518521
</div>
519522
</div>
520523
))}
524+
{recipientCount > 0 && (
525+
<div className="text-xs text-gray-500 mt-3 pt-3 border-t border-gray-700/40">
526+
{recipientCount} / {MAX_RECIPIENTS} recipients
527+
</div>
528+
)}
521529
</div>
522530

523531
{parsedTotal > 0 && derivedLines.length > 0 && (
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
5+
interface ExpiryDatePickerProps {
6+
value: string;
7+
onChange: (iso: string) => void;
8+
onTimezoneChange?: (tz: string) => void;
9+
timezone?: string;
10+
error?: string;
11+
}
12+
13+
export default function ExpiryDatePicker({
14+
value,
15+
onChange,
16+
onTimezoneChange,
17+
timezone: externalTimezone,
18+
error,
19+
}: ExpiryDatePickerProps) {
20+
const [timezone, setTimezone] = useState<string>(() => {
21+
if (externalTimezone) return externalTimezone;
22+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
23+
});
24+
25+
const [timezones, setTimezones] = useState<string[]>([]);
26+
27+
useEffect(() => {
28+
try {
29+
const supported = Intl.supportedValuesOf('timeZone') as string[];
30+
setTimezones(supported);
31+
} catch {
32+
setTimezones([]);
33+
}
34+
}, []);
35+
36+
useEffect(() => {
37+
if (externalTimezone) {
38+
setTimezone(externalTimezone);
39+
}
40+
}, [externalTimezone]);
41+
42+
const handleTimezoneChange = (tz: string) => {
43+
setTimezone(tz);
44+
if (onTimezoneChange) {
45+
onTimezoneChange(tz);
46+
}
47+
};
48+
49+
const isExpired = value && new Date(value) < new Date();
50+
51+
return (
52+
<div className="space-y-3">
53+
<div className="grid grid-cols-2 gap-3">
54+
<div>
55+
<label htmlFor="expiry-date" className="block text-sm font-medium text-gray-300 mb-1">
56+
Expiry Date & Time
57+
</label>
58+
<input
59+
id="expiry-date"
60+
type="datetime-local"
61+
value={value}
62+
onChange={(e) => onChange(e.target.value)}
63+
aria-invalid={isExpired || !!error}
64+
aria-describedby={error ? 'expiry-error' : undefined}
65+
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"
66+
/>
67+
</div>
68+
69+
<div>
70+
<label htmlFor="timezone-select" className="block text-sm font-medium text-gray-300 mb-1">
71+
Timezone
72+
</label>
73+
<select
74+
id="timezone-select"
75+
value={timezone}
76+
onChange={(e) => handleTimezoneChange(e.target.value)}
77+
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"
78+
>
79+
{timezones.map((tz) => (
80+
<option key={tz} value={tz}>
81+
{tz}
82+
</option>
83+
))}
84+
</select>
85+
</div>
86+
</div>
87+
88+
{(error || isExpired) && (
89+
<div
90+
id="expiry-error"
91+
role="alert"
92+
className="text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg px-3 py-2"
93+
>
94+
{error || 'Expiry date cannot be in the past'}
95+
</div>
96+
)}
97+
</div>
98+
);
99+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
interface ExpiryDisplayProps {
4+
timestamp: number;
5+
overrideTimezone?: string;
6+
}
7+
8+
export default function ExpiryDisplay({
9+
timestamp,
10+
overrideTimezone,
11+
}: ExpiryDisplayProps) {
12+
const date = new Date(timestamp * 1000);
13+
const timezone = overrideTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
14+
15+
const formatted = new Intl.DateTimeFormat('en-US', {
16+
year: 'numeric',
17+
month: 'long',
18+
day: 'numeric',
19+
hour: '2-digit',
20+
minute: '2-digit',
21+
second: '2-digit',
22+
timeZone: timezone,
23+
timeZoneName: 'short',
24+
}).format(date);
25+
26+
const isExpired = date < new Date();
27+
28+
return (
29+
<div className={`text-sm ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>
30+
<span className="font-mono">{formatted}</span>
31+
{isExpired && <span className="ml-2 text-xs font-medium">(Expired)</span>}
32+
</div>
33+
);
34+
}

src/components/ui/TxHash.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import CopyButton from '@/components/CopyButton';
5+
6+
interface TxHashProps {
7+
hash: string;
8+
network?: 'testnet' | 'mainnet';
9+
}
10+
11+
export default function TxHash({ hash, network = 'testnet' }: TxHashProps) {
12+
const [expanded, setExpanded] = useState(false);
13+
14+
const explorerUrl =
15+
network === 'mainnet'
16+
? `https://stellar.expert/explorer/public/tx/${hash}`
17+
: `https://stellar.expert/explorer/testnet/tx/${hash}`;
18+
19+
if (expanded) {
20+
return (
21+
<div className="flex flex-col gap-2">
22+
<code className="font-mono text-xs bg-gray-800 dark:bg-gray-900 border border-gray-700 rounded px-3 py-2 break-all text-gray-200">
23+
{hash}
24+
</code>
25+
<div className="flex items-center gap-2">
26+
<CopyButton text={hash} className="text-xs px-2 py-1" />
27+
<button
28+
onClick={() => setExpanded(false)}
29+
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors text-gray-400 hover:text-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
30+
>
31+
Collapse
32+
</button>
33+
<a
34+
href={explorerUrl}
35+
target="_blank"
36+
rel="noopener noreferrer"
37+
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors text-indigo-400 hover:text-indigo-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 flex items-center gap-1"
38+
title="View on Stellar Expert"
39+
>
40+
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
41+
<path d="M11 3a1 1 0 10-2 0v1a1 1 0 102 0V3zM15.657 5.343a1 1 0 00-1.414-1.414l-.707.707a1 1 0 001.414 1.414l.707-.707zM18 10a1 1 0 01-1 1h-1a1 1 0 110-2h1a1 1 0 011 1zM15.657 14.657a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414l.707.707zM11 17a1 1 0 102 0v-1a1 1 0 10-2 0v1zM5.343 15.657a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zM5.343 5.343a1 1 0 01-1.414 1.414l-.707-.707A1 1 0 014.636 4.636l.707.707zM10 6a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4z" />
42+
</svg>
43+
Explorer
44+
</a>
45+
</div>
46+
</div>
47+
);
48+
}
49+
50+
const truncated = `${hash.slice(0, 8)}...${hash.slice(-6)}`;
51+
52+
return (
53+
<div className="flex items-center gap-2">
54+
<code className="font-mono text-xs bg-gray-800 dark:bg-gray-900 border border-gray-700 rounded px-2 py-1 text-gray-200 select-all">
55+
{truncated}
56+
</code>
57+
<button
58+
onClick={() => setExpanded(true)}
59+
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors text-gray-400 hover:text-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
60+
title="Show full transaction hash"
61+
>
62+
Show full
63+
</button>
64+
<CopyButton text={hash} className="text-xs px-2 py-1" />
65+
</div>
66+
);
67+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Modal from '@/components/ui/Modal';
5+
6+
interface DisconnectModalProps {
7+
open: boolean;
8+
onClose: () => void;
9+
onConfirm: () => Promise<void>;
10+
walletAddress?: string;
11+
}
12+
13+
export default function DisconnectModal({
14+
open,
15+
onClose,
16+
onConfirm,
17+
walletAddress,
18+
}: DisconnectModalProps) {
19+
const [confirming, setConfirming] = useState(false);
20+
21+
const handleConfirm = async () => {
22+
setConfirming(true);
23+
try {
24+
await onConfirm();
25+
} finally {
26+
setConfirming(false);
27+
}
28+
};
29+
30+
const truncatedAddress = walletAddress
31+
? `${walletAddress.slice(0, 8)}...${walletAddress.slice(-6)}`
32+
: '';
33+
34+
return (
35+
<Modal open={open} onClose={onClose} title="Disconnect Wallet">
36+
<div className="space-y-4">
37+
<div className="text-sm text-gray-600 dark:text-gray-400">
38+
<p className="mb-2">
39+
Are you sure you want to disconnect your wallet?
40+
</p>
41+
{truncatedAddress && (
42+
<p className="text-xs text-gray-500 dark:text-gray-500 break-all">
43+
Address: <code className="font-mono">{truncatedAddress}</code>
44+
</p>
45+
)}
46+
<p className="mt-3 text-sm font-medium text-amber-600 dark:text-amber-500">
47+
You'll need to reconnect to continue using the app.
48+
</p>
49+
</div>
50+
51+
<div className="flex gap-3 pt-2">
52+
<button
53+
onClick={onClose}
54+
disabled={confirming}
55+
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 text-sm font-medium"
56+
>
57+
Cancel
58+
</button>
59+
<button
60+
onClick={handleConfirm}
61+
disabled={confirming}
62+
className="flex-1 px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium flex items-center justify-center gap-2"
63+
>
64+
{confirming && (
65+
<svg
66+
className="animate-spin h-4 w-4"
67+
xmlns="http://www.w3.org/2000/svg"
68+
fill="none"
69+
viewBox="0 0 24 24"
70+
aria-hidden="true"
71+
>
72+
<circle
73+
className="opacity-25"
74+
cx="12"
75+
cy="12"
76+
r="10"
77+
stroke="currentColor"
78+
strokeWidth="4"
79+
/>
80+
<path
81+
className="opacity-75"
82+
fill="currentColor"
83+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
84+
/>
85+
</svg>
86+
)}
87+
Disconnect
88+
</button>
89+
</div>
90+
</div>
91+
</Modal>
92+
);
93+
}

0 commit comments

Comments
 (0)