diff --git a/src/app/api/invoices/drafts/route.ts b/src/app/api/invoices/drafts/route.ts
index 57732c8..e6d267a 100644
--- a/src/app/api/invoices/drafts/route.ts
+++ b/src/app/api/invoices/drafts/route.ts
@@ -30,3 +30,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ received: true, draftId });
}
+
+/**
+ * Reports the server's last-known `updatedAt` for a draft, so the client can
+ * detect a conflict before overwriting it. There's no database in this
+ * project, so there's never a stored server version — the client treats a
+ * missing `updatedAt` as "no conflict" and syncs normally.
+ */
+export async function GET(request: NextRequest) {
+ const draftId = request.nextUrl.searchParams.get("draftId");
+ const userId = request.nextUrl.searchParams.get("userId");
+
+ if (!draftId || !userId) {
+ return NextResponse.json({ error: "draftId and userId are required" }, { status: 400 });
+ }
+
+ return NextResponse.json({ updatedAt: null });
+}
diff --git a/src/components/CollaborationCursors.tsx b/src/components/CollaborationCursors.tsx
index e1a7662..03359c3 100644
--- a/src/components/CollaborationCursors.tsx
+++ b/src/components/CollaborationCursors.tsx
@@ -87,72 +87,81 @@ export default function CollaborationCursors({ invoiceId, currentAddress }: Prop
if (others.length === 0) return null;
return (
-
-
+ )}
+
+ >
);
}
diff --git a/src/hooks/useFiatRate.ts b/src/hooks/useFiatRate.ts
index edd7fdc..c0f26a7 100644
--- a/src/hooks/useFiatRate.ts
+++ b/src/hooks/useFiatRate.ts
@@ -21,6 +21,8 @@ interface RateState {
loading: boolean;
/** Set when the last fetch failed and no usable rate is available. */
error: string | null;
+ /** Timestamp (ms) of the last successful fetch, used to derive `isStale`. */
+ lastFetchedAt: number | null;
}
interface FiatRateContextValue extends RateState {
@@ -29,6 +31,8 @@ interface FiatRateContextValue extends RateState {
currency: FiatCurrency;
}
+const DEFAULT_TTL_SECONDS = 60;
+
const FiatRateContext = createContext(null);
/**
@@ -43,6 +47,7 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) {
rates: null,
loading: true,
error: null,
+ lastFetchedAt: null,
});
const mounted = useRef(true);
@@ -59,12 +64,12 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) {
if (!body.rates) throw new Error("Malformed rate response");
if (mounted.current) {
- setState({ rates: body.rates, loading: false, error: null });
+ setState({ rates: body.rates, loading: false, error: null, lastFetchedAt: Date.now() });
}
} catch (error) {
if (controller.signal.aborted || !mounted.current) return;
const message = error instanceof Error ? error.message : String(error);
- setState({ rates: null, loading: false, error: message });
+ setState((prev) => ({ rates: null, loading: false, error: message, lastFetchedAt: prev.lastFetchedAt }));
}
};
@@ -96,15 +101,26 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) {
/**
* Read the current fiat rate for the user's preferred currency.
*
- * Returns a neutral loading state outside a provider so a component tree that
- * has not mounted the provider degrades instead of throwing.
+ * `ttl` (seconds, default 60) controls how long the cached rate is considered
+ * fresh; once exceeded, `isStale` is true while the last-known rate is still
+ * returned. Returns a neutral loading state outside a provider so a component
+ * tree that has not mounted the provider degrades instead of throwing.
*/
-export function useFiatRate(): FiatRateContextValue {
+export function useFiatRate(ttl: number = DEFAULT_TTL_SECONDS): FiatRateContextValue & { isStale: boolean } {
const ctx = useContext(FiatRateContext);
if (!ctx) {
- return { rates: null, rate: null, loading: false, error: "Rate unavailable", currency: "USD" };
+ return {
+ rates: null,
+ rate: null,
+ loading: false,
+ error: "Rate unavailable",
+ currency: "USD",
+ lastFetchedAt: null,
+ isStale: false,
+ };
}
- return ctx;
+ const isStale = ctx.lastFetchedAt !== null && Date.now() - ctx.lastFetchedAt > ttl * 1000;
+ return { ...ctx, isStale };
}
export default useFiatRate;
diff --git a/src/hooks/useOfflineDraftAutosave.ts b/src/hooks/useOfflineDraftAutosave.ts
index 3004a63..932a67b 100644
--- a/src/hooks/useOfflineDraftAutosave.ts
+++ b/src/hooks/useOfflineDraftAutosave.ts
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { deleteDraft, getDraft, putDraft, type DraftFormData } from "@/lib/offlineDraftDB";
+import { deleteDraft, getDraft, putDraft, type DraftFormData, type StoredDraft } from "@/lib/offlineDraftDB";
import { apiFetch } from "@/lib/apiClient";
const AUTOSAVE_INTERVAL_MS = 5_000;
@@ -21,7 +21,8 @@ export interface UseOfflineDraftAutosaveResult {
export function useOfflineDraftAutosave(
userId: string,
draftId: string,
- data: DraftFormData
+ data: DraftFormData,
+ onConflict?: (local: StoredDraft, server: { updatedAt: number }) => void
): UseOfflineDraftAutosaveResult {
const [isOffline, setIsOffline] = useState(
() => typeof navigator !== "undefined" && !navigator.onLine
@@ -29,6 +30,8 @@ export function useOfflineDraftAutosave(
const [lastSavedAt, setLastSavedAt] = useState(null);
const dataRef = useRef(data);
dataRef.current = data;
+ const onConflictRef = useRef(onConflict);
+ onConflictRef.current = onConflict;
const flush = useCallback(async () => {
if (!userId || !draftId) return;
@@ -36,6 +39,22 @@ export function useOfflineDraftAutosave(
const draft = await getDraft(userId, draftId);
if (!draft) return;
+ try {
+ const serverRes = await apiFetch(
+ `/api/invoices/drafts?${new URLSearchParams({ userId, draftId })}`
+ );
+ if (serverRes.ok) {
+ const serverBody = await serverRes.json().catch(() => null);
+ const serverUpdatedAt = serverBody?.updatedAt;
+ if (typeof serverUpdatedAt === "number" && serverUpdatedAt > draft.updatedAt) {
+ onConflictRef.current?.(draft, { updatedAt: serverUpdatedAt });
+ return;
+ }
+ }
+ } catch {
+ // Can't reach the server to check for a conflict — fall through and sync as before.
+ }
+
try {
const res = await apiFetch("/api/invoices/drafts", {
method: "POST",
diff --git a/src/hooks/useXlmUsdcRate.ts b/src/hooks/useXlmUsdcRate.ts
index ed4410d..ee6af13 100644
--- a/src/hooks/useXlmUsdcRate.ts
+++ b/src/hooks/useXlmUsdcRate.ts
@@ -4,6 +4,13 @@ import { useState, useEffect, useRef } from "react";
const REFRESH_MS = 60_000;
+export interface UseXlmUsdcRateOptions {
+ /** Percentage move (e.g. 5 for 5%) from the rate at hook initialisation that triggers onAlert. */
+ alertThreshold?: number;
+ /** Called at most once per polling interval when the rate crosses alertThreshold. */
+ onAlert?: (direction: "up" | "down", changePercent: number) => void;
+}
+
/**
* useXlmUsdcRate
*
@@ -13,12 +20,16 @@ const REFRESH_MS = 60_000;
*
* Returns null while loading or when the rate is unavailable.
*/
-export function useXlmUsdcRate(): number | null {
+export function useXlmUsdcRate(options?: UseXlmUsdcRateOptions): number | null {
const [rate, setRate] = useState(null);
const mounted = useRef(true);
+ const baseRateRef = useRef(null);
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
useEffect(() => {
mounted.current = true;
+ baseRateRef.current = null;
const load = async () => {
try {
@@ -32,6 +43,17 @@ export function useXlmUsdcRate(): number | null {
const data = await res.json();
const xlmUsd: number | undefined = data?.stellar?.usd;
if (typeof xlmUsd === "number" && xlmUsd > 0 && mounted.current) {
+ if (baseRateRef.current === null) {
+ baseRateRef.current = xlmUsd;
+ } else {
+ const { alertThreshold, onAlert } = optionsRef.current ?? {};
+ if (alertThreshold && onAlert) {
+ const changePercent = ((xlmUsd - baseRateRef.current) / baseRateRef.current) * 100;
+ if (Math.abs(changePercent) >= alertThreshold) {
+ onAlert(changePercent > 0 ? "up" : "down", Math.abs(changePercent));
+ }
+ }
+ }
setRate(xlmUsd);
}
} catch {