Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/components/DeadlineCountdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);

Expand Down Expand Up @@ -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}
</time>
);
}
Expand Down
43 changes: 35 additions & 8 deletions src/components/FeeOptimizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeeEstimate | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [appliedFee, setAppliedFee] = useState<bigint | null>(null);

const fetchFee = async () => {
try {
Expand Down Expand Up @@ -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 (
<div className="bg-gray-900 rounded-lg px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
Expand All @@ -83,13 +99,24 @@ export default function FeeOptimizer() {
{fee.congestion === "Medium" && "⏳ Consider waiting"}
{fee.congestion === "High" && "⚠ High fees — wait if possible"}
</span>
<button
type="button"
onClick={fetchFee}
className="text-xs px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 transition-colors"
>
Refresh
</button>
<div className="flex items-center gap-2">
{!suggestionAccepted && (
<button
type="button"
onClick={handleAcceptSuggestion}
className="text-xs px-2 py-1 rounded bg-indigo-700 hover:bg-indigo-600 transition-colors"
>
Accept Suggestion
</button>
)}
<button
type="button"
onClick={fetchFee}
className="text-xs px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 transition-colors"
>
Refresh
</button>
</div>
</div>
{lastUpdate && (
<p className="text-xs text-gray-600 text-right">
Expand Down
21 changes: 20 additions & 1 deletion src/components/NotificationCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
void ids;
}

function getSubscribedIds(): string[] {
if (typeof window === "undefined") return [];
try {
Expand Down Expand Up @@ -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) => {
Expand Down
106 changes: 101 additions & 5 deletions src/components/ReminderSender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | null>(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);
Expand Down Expand Up @@ -99,22 +153,60 @@ export default function ReminderSender({
{reminderText}
</div>

{/* Schedule for later */}
<div className="mb-4">
<label htmlFor="reminder-send-at" className="block text-xs text-gray-400 mb-1">
Send at (optional)
</label>
<input
id="reminder-send-at"
type="datetime-local"
value={sendAt}
onChange={(e) => 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 && (
<p id="reminder-send-at-error" className="text-xs text-red-400 mt-1">
{sendAtError}
</p>
)}
{isScheduling && !sendAtError && (
<div className="mt-2">
<button
type="button"
onClick={handleScheduleReminder}
className="min-h-11 px-4 py-2 rounded-lg bg-amber-700 hover:bg-amber-600 text-sm font-semibold transition-colors"
aria-live="polite"
>
{scheduled ? "Scheduled!" : "Schedule Reminder"}
</button>
</div>
)}
</div>

{/* Action buttons */}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={handleCopy}
className="min-h-11 flex-1 sm:flex-none px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-sm font-semibold transition-colors"
disabled={isScheduling}
className="min-h-11 flex-1 sm:flex-none px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-sm font-semibold transition-colors"
aria-live="polite"
>
{copied ? "Copied!" : "Copy Reminder"}
</button>

<a
href={whatsappUrl}
href={isScheduling ? undefined : whatsappUrl}
target="_blank"
rel="noopener noreferrer"
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"
aria-disabled={isScheduling}
onClick={(e) => { 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"
>
<svg
Expand All @@ -130,10 +222,14 @@ export default function ReminderSender({
</a>

<a
href={telegramUrl}
href={isScheduling ? undefined : telegramUrl}
target="_blank"
rel="noopener noreferrer"
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"
aria-disabled={isScheduling}
onClick={(e) => { 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"
>
<svg
Expand Down