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
40 changes: 39 additions & 1 deletion src/components/DeadlineSuggester.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ export default function DeadlineSuggester({
onUseSuggestion,
}: Props) {
const [suggestion, setSuggestion] = useState<number | null>(null);
const [reason, setReason] = useState<string>("");
const [tooltipOpen, setTooltipOpen] = useState(false);

useEffect(() => {
if (!totalAmount || recipientCount === 0) {
setSuggestion(null);
setReason("");
return;
}

const amount = parseFloat(totalAmount);
if (isNaN(amount) || amount <= 0) {
setSuggestion(null);
setReason("");
return;
}

Expand All @@ -38,6 +42,7 @@ export default function DeadlineSuggester({
const history: HistoricalInvoice[] = stored ? JSON.parse(stored) : [];

let recommendedDays: number;
let recommendationReason: string;

if (history.length > 0) {
// Filter by similar amount bracket (within 50% to 150%)
Expand All @@ -50,16 +55,26 @@ export default function DeadlineSuggester({
const avgTime =
similar.reduce((sum, inv) => sum + inv.fundingTime, 0) / similar.length;
recommendedDays = Math.ceil(avgTime * 1.2); // Add 20% buffer
recommendationReason = `Based on your average payment cycle of ${avgTime.toFixed(
1
)} days for ${similar.length} similar invoice${similar.length === 1 ? "" : "s"}, plus a 20% buffer.`;
} else {
// Use static rules as fallback
recommendedDays = getStaticRecommendation(amount);
recommendationReason = `No similar past invoices found, so we used a standard recommendation for invoices around $${amount.toFixed(
2
)}.`;
}
} else {
// No history, use static rules
recommendedDays = getStaticRecommendation(amount);
recommendationReason = `You have no invoice history yet, so we used a standard recommendation for invoices around $${amount.toFixed(
2
)}.`;
}

setSuggestion(Math.max(1, Math.min(365, recommendedDays)));
setReason(recommendationReason);
}, [totalAmount, recipientCount]);

if (suggestion === null) {
Expand All @@ -68,8 +83,31 @@ export default function DeadlineSuggester({

return (
<div className="mt-2 flex items-center justify-between bg-indigo-950 border border-indigo-700 rounded-lg px-3 py-2">
<p className="text-sm text-indigo-200">
<p className="text-sm text-indigo-200 flex items-center gap-1">
Recommended: <span className="font-semibold">{suggestion} days</span>
<span className="relative inline-flex">
<button
type="button"
aria-label="Why this suggestion?"
aria-describedby="deadline-suggestion-reason"
onFocus={() => setTooltipOpen(true)}
onBlur={() => setTooltipOpen(false)}
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full border border-indigo-400 text-[10px] leading-none text-indigo-300 hover:bg-indigo-800"
>
?
</button>
{tooltipOpen && (
<span
id="deadline-suggestion-reason"
role="tooltip"
className="absolute bottom-full left-1/2 z-10 mb-2 w-56 -translate-x-1/2 rounded-md bg-gray-900 px-2 py-1.5 text-xs text-gray-100 shadow-lg"
>
{reason}
</span>
)}
</span>
</p>
<button
type="button"
Expand Down
53 changes: 49 additions & 4 deletions src/components/PaymentVelocityGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@ import {
type PaymentVelocityAlert,
} from "@/hooks/usePaymentVelocity";

interface ZoneBoundaries {
/** Upper bound (percent, 0-100) of the red zone. */
redMax: number;
/** Upper bound (percent, 0-100) of the yellow zone. */
yellowMax: number;
}

interface Props {
/** Connected account to track; omit to show zeroed gauges. */
account?: string | null;
/** Optional zone boundary overrides; defaults to red 0-33%, yellow 34-66%, green 67-100%. */
zoneBoundaries?: ZoneBoundaries;
}

const DEFAULT_ZONE_BOUNDARIES: ZoneBoundaries = { redMax: 33, yellowMax: 66 };

const WINDOW_LABELS: Record<VelocityWindow, string> = {
"1h": "1h",
"24h": "24h",
Expand All @@ -31,12 +42,26 @@ function formatMoney(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}

/** Point on the half-circle arc (180deg at pct=0, 0deg at pct=100) centered at (cx, 80). */
function pointOnArc(cx: number, r: number, pct: number): { x: number; y: number } {
const theta = (Math.PI * (100 - pct)) / 100; // radians, PI at pct=0, 0 at pct=100
return { x: cx + r * Math.cos(theta), y: 80 - r * Math.sin(theta) };
}

/** SVG path for the arc segment spanning [pctStart, pctEnd] along the gauge's half circle. */
function arcSegmentPath(cx: number, r: number, pctStart: number, pctEnd: number): string {
const start = pointOnArc(cx, r, pctStart);
const end = pointOnArc(cx, r, pctEnd);
return `M ${start.x} ${start.y} A ${r} ${r} 0 0 1 ${end.x} ${end.y}`;
}

/**
* PaymentVelocityGauge (#408) — SVG gauges for the rolling 1h / 24h / 7d
* outgoing payment volume of the connected account, with inline threshold
* configuration and an alert banner when a threshold is breached.
*/
export default function PaymentVelocityGauge({ account }: Props) {
export default function PaymentVelocityGauge({ account, zoneBoundaries }: Props) {
const { redMax, yellowMax } = zoneBoundaries ?? DEFAULT_ZONE_BOUNDARIES;
const { velocities, lastUpdated, loading, error, thresholds, setThreshold } =
usePaymentVelocity(account);
const [alerts, setAlerts] = useState<PaymentVelocityAlert[]>([]);
Expand Down Expand Up @@ -146,11 +171,31 @@ export default function PaymentVelocityGauge({ account }: Props) {
return (
<g key={window}>
<path
d={`M ${cx - r} 80 A ${r} ${r} 0 0 1 ${cx + r} 80`}
stroke="#374151"
strokeWidth="10"
data-testid={`gauge-zone-red-${window}`}
d={arcSegmentPath(cx, r, 0, redMax)}
stroke="#ef4444"
strokeWidth="6"
fill="none"
strokeLinecap="round"
opacity={0.35}
/>
<path
data-testid={`gauge-zone-yellow-${window}`}
d={arcSegmentPath(cx, r, redMax, yellowMax)}
stroke="#f59e0b"
strokeWidth="6"
fill="none"
strokeLinecap="round"
opacity={0.35}
/>
<path
data-testid={`gauge-zone-green-${window}`}
d={arcSegmentPath(cx, r, yellowMax, 100)}
stroke="#10b981"
strokeWidth="6"
fill="none"
strokeLinecap="round"
opacity={0.35}
/>
<path
data-testid={`gauge-${window}`}
Expand Down
10 changes: 9 additions & 1 deletion src/components/wallet/WalletBalanceDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,22 @@ function SkeletonText() {
}

export default function WalletBalanceDisplay({ address, isOpen = true }: Props) {
const { xlmBalance, usdcBalance, isLoading, refetch } = useWalletBalance(address, isOpen && !!address);
const { xlmBalance, usdcBalance, isLoading, isRetrying, refetch } = useWalletBalance(
address,
isOpen && !!address
);

if (!address) {
return null;
}

return (
<div className="flex flex-col gap-3 py-2">
{isRetrying && (
<div className="text-xs text-amber-400" role="status">
Retrying...
</div>
)}
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="text-xs text-gray-400 mb-1">XLM Balance</div>
Expand Down
70 changes: 12 additions & 58 deletions src/hooks/useInvoiceForm.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,21 @@
'use client';

import { useCallback, useState, useMemo } from 'react';
import { useInvoiceMeta } from './useInvoiceMeta';
import { useLineItems } from './useLineItems';

interface InvoiceFormState {
expiryDate: string;
timezone: string;
}
export type { LineItem } from './useLineItems';

/**
* Composes `useInvoiceMeta` (top-level invoice fields + validation) and
* `useLineItems` (item CRUD, totals, reordering) into the unified API
* existing callers already depend on. See #635.
*/
export function useInvoiceForm() {
const [state, setState] = useState<InvoiceFormState>(() => ({
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<string, string> = {};

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]);
const meta = useInvoiceMeta();
const lineItems = useLineItems();

return {
expiryDate: state.expiryDate,
timezone: state.timezone,
setExpiryDate,
setTimezone,
validation,
getUtcTimestamp,
convertToUtcIso,
...meta,
...lineItems,
};
}
72 changes: 72 additions & 0 deletions src/hooks/useInvoiceMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client';

import { useCallback, useState, useMemo } from 'react';

interface InvoiceMetaState {
expiryDate: string;
timezone: string;
}

/**
* Invoice-level metadata: expiry date, timezone, and their validation.
* Split out of `useInvoiceForm` (#635) so metadata concerns are testable
* independently of line-item state.
*/
export function useInvoiceMeta() {
const [state, setState] = useState<InvoiceMetaState>(() => ({
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<string, string> = {};

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,
};
}
Loading