Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/components/SplitCalculator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -230,7 +231,9 @@ export default function SplitCalculator({
<button
type="button"
onClick={addRecipient}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors"
disabled={!canAddRecipient}
title={!canAddRecipient ? `Maximum ${MAX_RECIPIENTS} recipients reached` : undefined}
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"
>
+ Add Line
</button>
Expand Down Expand Up @@ -518,6 +521,11 @@ export default function SplitCalculator({
</div>
</div>
))}
{recipientCount > 0 && (
<div className="text-xs text-gray-500 mt-3 pt-3 border-t border-gray-700/40">
{recipientCount} / {MAX_RECIPIENTS} recipients
</div>
)}
</div>

{parsedTotal > 0 && derivedLines.length > 0 && (
Expand Down
99 changes: 99 additions & 0 deletions src/components/invoice/ExpiryDatePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(() => {
if (externalTimezone) return externalTimezone;
return Intl.DateTimeFormat().resolvedOptions().timeZone;
});

const [timezones, setTimezones] = useState<string[]>([]);

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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="expiry-date" className="block text-sm font-medium text-gray-300 mb-1">
Expiry Date & Time
</label>
<input
id="expiry-date"
type="datetime-local"
value={value}
onChange={(e) => 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"
/>
</div>

<div>
<label htmlFor="timezone-select" className="block text-sm font-medium text-gray-300 mb-1">
Timezone
</label>
<select
id="timezone-select"
value={timezone}
onChange={(e) => handleTimezoneChange(e.target.value)}
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"
>
{timezones.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
</div>

{(error || isExpired) && (
<div
id="expiry-error"
role="alert"
className="text-sm text-red-400 bg-red-950/40 border border-red-800 rounded-lg px-3 py-2"
>
{error || 'Expiry date cannot be in the past'}
</div>
)}
</div>
);
}
34 changes: 34 additions & 0 deletions src/components/invoice/ExpiryDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`text-sm ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>
<span className="font-mono">{formatted}</span>
{isExpired && <span className="ml-2 text-xs font-medium">(Expired)</span>}
</div>
);
}
67 changes: 67 additions & 0 deletions src/components/ui/TxHash.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-2">
<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">
{hash}
</code>
<div className="flex items-center gap-2">
<CopyButton text={hash} className="text-xs px-2 py-1" />
<button
onClick={() => setExpanded(false)}
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"
>
Collapse
</button>
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
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"
title="View on Stellar Expert"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<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" />
</svg>
Explorer
</a>
</div>
</div>
);
}

const truncated = `${hash.slice(0, 8)}...${hash.slice(-6)}`;

return (
<div className="flex items-center gap-2">
<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">
{truncated}
</code>
<button
onClick={() => setExpanded(true)}
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"
title="Show full transaction hash"
>
Show full
</button>
<CopyButton text={hash} className="text-xs px-2 py-1" />
</div>
);
}
93 changes: 93 additions & 0 deletions src/components/wallet/DisconnectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client';

import { useState } from 'react';
import Modal from '@/components/ui/Modal';

interface DisconnectModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
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 (
<Modal open={open} onClose={onClose} title="Disconnect Wallet">
<div className="space-y-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
<p className="mb-2">
Are you sure you want to disconnect your wallet?
</p>
{truncatedAddress && (
<p className="text-xs text-gray-500 dark:text-gray-500 break-all">
Address: <code className="font-mono">{truncatedAddress}</code>
</p>
)}
<p className="mt-3 text-sm font-medium text-amber-600 dark:text-amber-500">
You'll need to reconnect to continue using the app.
</p>
</div>

<div className="flex gap-3 pt-2">
<button
onClick={onClose}
disabled={confirming}
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"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={confirming}
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"
>
{confirming && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
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"
/>
</svg>
)}
Disconnect
</button>
</div>
</div>
</Modal>
);
}
Loading
Loading