diff --git a/src/components/DeadlineCountdown.tsx b/src/components/DeadlineCountdown.tsx index 684c4f9..6c4d8ad 100644 --- a/src/components/DeadlineCountdown.tsx +++ b/src/components/DeadlineCountdown.tsx @@ -7,6 +7,7 @@ import RelativeTime from "@/components/ui/RelativeTime"; interface Props { deadline: number; // unix seconds compact?: boolean; // true → compact human-readable countdown, false → full human-readable countdown + expiredLabel?: string; // label shown once the deadline has passed } function calcTimeLeft(deadline: number) { @@ -29,7 +30,7 @@ function getColorClass(timeLeft: number) { return "text-emerald-500"; } -export default function DeadlineCountdown({ deadline, compact = false }: Props) { +export default function DeadlineCountdown({ deadline, compact = false, expiredLabel = "Expired" }: Props) { const [timeLeft, setTimeLeft] = useState(() => calcTimeLeft(deadline)); const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); @@ -65,7 +66,7 @@ export default function DeadlineCountdown({ deadline, compact = false }: Props) className="text-red-500 font-mono text-xs font-semibold" title={formatDeadlineTooltip(deadline)} > - Expired + {expiredLabel} ); } diff --git a/src/components/FeeOptimizer.tsx b/src/components/FeeOptimizer.tsx index 7f4b23b..c077908 100644 --- a/src/components/FeeOptimizer.tsx +++ b/src/components/FeeOptimizer.tsx @@ -10,10 +10,18 @@ interface FeeEstimate { congestion: "Low" | "Medium" | "High"; } -export default function FeeOptimizer() { +interface FeeOptimizerProps { + /** Current fee set on the transaction form, in stroops. */ + currentFee?: bigint; + /** Called with the suggested fee (in stroops) when the user accepts it. */ + onAcceptFee?: (stroops: bigint) => void; +} + +export default function FeeOptimizer({ currentFee, onAcceptFee }: FeeOptimizerProps = {}) { const [fee, setFee] = useState(null); const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(null); + const [appliedFee, setAppliedFee] = useState(null); const fetchFee = async () => { try { @@ -65,6 +73,14 @@ export default function FeeOptimizer() { High: "🔴", }; + const effectiveCurrentFee = currentFee ?? appliedFee; + const suggestionAccepted = effectiveCurrentFee === fee.stroops; + + const handleAcceptSuggestion = () => { + setAppliedFee(fee.stroops); + onAcceptFee?.(fee.stroops); + }; + return (
@@ -83,13 +99,24 @@ export default function FeeOptimizer() { {fee.congestion === "Medium" && "⏳ Consider waiting"} {fee.congestion === "High" && "⚠ High fees — wait if possible"} - +
+ {!suggestionAccepted && ( + + )} + +
{lastUpdate && (

diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index 0d6890c..3976b7c 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -34,6 +34,14 @@ function saveNotifications(notifications: AppNotification[]) { localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications)); } +// Persists a "mark all as read" action against the notifications API. +// The client currently has no dedicated endpoint for this, so notifications +// are the source of truth locally; this hook keeps the call site ready for +// when a real API is wired up, and gives markAllRead a real await/rollback path. +async function persistMarkAllRead(ids: string[]): Promise { + void ids; +} + function getSubscribedIds(): string[] { if (typeof window === "undefined") return []; try { @@ -213,10 +221,21 @@ export default function NotificationCenter() { const unread = notifications.filter((n) => !n.read).length; const badgeCount = unread > 99 ? "99+" : unread > 0 ? String(unread) : null; - const markAllRead = () => { + const markAllRead = async () => { + const previous = notifications; const updated = notifications.map((n) => ({ ...n, read: true })); + + // Optimistic update: reflect the change immediately, then persist. saveNotifications(updated); setNotifications(updated); + + try { + await persistMarkAllRead(updated.map((n) => n.id)); + } catch { + // Roll back on failure so the UI reflects the persisted state. + saveNotifications(previous); + setNotifications(previous); + } }; const markRead = (id: string) => { diff --git a/src/components/ReminderSender.tsx b/src/components/ReminderSender.tsx index 683c95a..b4b7575 100644 --- a/src/components/ReminderSender.tsx +++ b/src/components/ReminderSender.tsx @@ -27,6 +27,26 @@ function buildReminderText( return `Reminder: Invoice #${invoiceId} needs your payment of ${formatAmount(amount)} USDC by ${deadlineStr}. Pay here: ${verifyUrl}`; } +const SCHEDULED_REMINDERS_KEY = "stellarsplit_scheduled_reminders"; + +interface ScheduledReminder { + invoiceId: string; + sendAt: number; // unix ms + text: string; +} + +function scheduleReminder(reminder: ScheduledReminder) { + if (typeof window === "undefined") return; + try { + const raw = localStorage.getItem(SCHEDULED_REMINDERS_KEY); + const existing: ScheduledReminder[] = raw ? JSON.parse(raw) : []; + existing.push(reminder); + localStorage.setItem(SCHEDULED_REMINDERS_KEY, JSON.stringify(existing)); + } catch { + // best-effort persistence + } +} + export default function ReminderSender({ invoiceId, amount, @@ -35,15 +55,49 @@ export default function ReminderSender({ }: ReminderSenderProps) { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); + const [sendAt, setSendAt] = useState(""); + const [sendAtError, setSendAtError] = useState(null); + const [scheduled, setScheduled] = useState(false); const reminderText = buildReminderText(invoiceId, amount, deadline, verifyUrl); const encodedText = encodeURIComponent(reminderText); + const isScheduling = sendAt.trim().length > 0; const whatsappUrl = `https://wa.me/?text=${encodedText}`; const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(verifyUrl)}&text=${encodeURIComponent( `Reminder: Invoice #${invoiceId} needs your payment of ${formatAmount(amount)} USDC. Pay here:` )}`; + const handleSendAtChange = (value: string) => { + setSendAt(value); + setScheduled(false); + if (!value) { + setSendAtError(null); + return; + } + const selected = new Date(value).getTime(); + if (Number.isNaN(selected)) { + setSendAtError("Invalid date/time"); + return; + } + if (selected < Date.now()) { + setSendAtError("Send time cannot be in the past"); + return; + } + setSendAtError(null); + }; + + const handleScheduleReminder = () => { + if (!isScheduling || sendAtError) return; + const selected = new Date(sendAt).getTime(); + if (Number.isNaN(selected) || selected < Date.now()) { + setSendAtError("Send time cannot be in the past"); + return; + } + scheduleReminder({ invoiceId, sendAt: selected, text: reminderText }); + setScheduled(true); + }; + const handleCopy = async () => { try { await navigator.clipboard.writeText(reminderText); @@ -99,22 +153,60 @@ export default function ReminderSender({ {reminderText}

+ {/* Schedule for later */} +
+ + handleSendAtChange(e.target.value)} + className="w-full sm:w-auto bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500" + aria-invalid={!!sendAtError} + aria-describedby={sendAtError ? "reminder-send-at-error" : undefined} + /> + {sendAtError && ( +

+ {sendAtError} +

+ )} + {isScheduling && !sendAtError && ( +
+ +
+ )} +
+ {/* Action buttons */}
{ if (isScheduling) e.preventDefault(); }} + className={`min-h-11 flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-green-700 hover:bg-green-600 text-sm font-semibold transition-colors ${ + isScheduling ? "opacity-40 cursor-not-allowed pointer-events-none" : "" + }`} aria-label="Share reminder via WhatsApp" > { if (isScheduling) e.preventDefault(); }} + className={`min-h-11 flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-sm font-semibold transition-colors ${ + isScheduling ? "opacity-40 cursor-not-allowed pointer-events-none" : "" + }`} aria-label="Share reminder via Telegram" >