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
17 changes: 17 additions & 0 deletions src/app/api/invoices/drafts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
133 changes: 71 additions & 62 deletions src/components/CollaborationCursors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,72 +87,81 @@ export default function CollaborationCursors({ invoiceId, currentAddress }: Prop
if (others.length === 0) return null;

return (
<div
className="fixed right-2 sm:right-3 top-1/2 -translate-y-1/2 z-40 flex flex-col items-center gap-1.5"
aria-label="Other viewers' scroll positions"
>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="w-5 h-5 flex items-center justify-center rounded bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 text-xs transition-colors"
aria-expanded={!collapsed}
aria-label={collapsed ? "Show viewer positions" : "Hide viewer positions"}
<>
<div
className="fixed right-2 sm:right-3 top-2 z-40 flex items-center gap-1 rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-xs text-gray-300"
aria-label={`${others.length} collaborator${others.length === 1 ? "" : "s"} online`}
>
{collapsed ? "▸" : "◂"}
</button>

{!collapsed && (
<div
role="img"
aria-label="Minimap of viewer positions"
className="relative bg-gray-900 border border-gray-700 rounded"
style={{ width: 20, height: MINIMAP_HEIGHT }}
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" aria-hidden="true" />
{others.length} online
</div>
<div
className="fixed right-2 sm:right-3 top-1/2 -translate-y-1/2 z-40 flex flex-col items-center gap-1.5"
aria-label="Other viewers' scroll positions"
>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="w-5 h-5 flex items-center justify-center rounded bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 text-xs transition-colors"
aria-expanded={!collapsed}
aria-label={collapsed ? "Show viewer positions" : "Hide viewer positions"}
>
{/* Track line */}
<div className="absolute inset-x-0 top-0 bottom-0 mx-auto w-px bg-gray-700" />

{others.map((cursor) => {
const topPct = Math.min((cursor.scrollY / pageHeight) * 100, 94);
const color = colorForAddress(cursor.address);
return (
{collapsed ? "▸" : "◂"}
</button>

{!collapsed && (
<div
role="img"
aria-label="Minimap of viewer positions"
className="relative bg-gray-900 border border-gray-700 rounded"
style={{ width: 20, height: MINIMAP_HEIGHT }}
>
{/* Track line */}
<div className="absolute inset-x-0 top-0 bottom-0 mx-auto w-px bg-gray-700" />

{others.map((cursor) => {
const topPct = Math.min((cursor.scrollY / pageHeight) * 100, 94);
const color = colorForAddress(cursor.address);
return (
<div
key={cursor.address}
title={truncateAddress(cursor.address)}
style={{
position: "absolute",
top: `${topPct}%`,
left: "50%",
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: color,
boxShadow: `0 0 5px ${color}80`,
flexShrink: 0,
}}
/>
);
})}
</div>
)}

{!collapsed && (
<div className="flex flex-col gap-1 items-center" aria-label="Viewer legend">
{others.slice(0, 4).map((cursor) => (
<div
key={cursor.address}
title={truncateAddress(cursor.address)}
style={{
position: "absolute",
top: `${topPct}%`,
left: "50%",
transform: "translate(-50%, -50%)",
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: color,
boxShadow: `0 0 5px ${color}80`,
flexShrink: 0,
}}
className="w-3 h-3 rounded-full"
title={cursor.address}
style={{ backgroundColor: colorForAddress(cursor.address) }}
/>
);
})}
</div>
)}

{!collapsed && (
<div className="flex flex-col gap-1 items-center" aria-label="Viewer legend">
{others.slice(0, 4).map((cursor) => (
<div
key={cursor.address}
className="w-3 h-3 rounded-full"
title={cursor.address}
style={{ backgroundColor: colorForAddress(cursor.address) }}
/>
))}
{others.length > 4 && (
<span className="text-gray-500" style={{ fontSize: 9 }}>
+{others.length - 4}
</span>
)}
</div>
)}
</div>
))}
{others.length > 4 && (
<span className="text-gray-500" style={{ fontSize: 9 }}>
+{others.length - 4}
</span>
)}
</div>
)}
</div>
</>
);
}
30 changes: 23 additions & 7 deletions src/hooks/useFiatRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,6 +31,8 @@ interface FiatRateContextValue extends RateState {
currency: FiatCurrency;
}

const DEFAULT_TTL_SECONDS = 60;

const FiatRateContext = createContext<FiatRateContextValue | null>(null);

/**
Expand All @@ -43,6 +47,7 @@ export function FiatRateProvider({ children }: { children: React.ReactNode }) {
rates: null,
loading: true,
error: null,
lastFetchedAt: null,
});
const mounted = useRef(true);

Expand All @@ -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 }));
}
};

Expand Down Expand Up @@ -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;
23 changes: 21 additions & 2 deletions src/hooks/useOfflineDraftAutosave.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,21 +21,40 @@ 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
);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const dataRef = useRef(data);
dataRef.current = data;
const onConflictRef = useRef(onConflict);
onConflictRef.current = onConflict;

const flush = useCallback(async () => {
if (!userId || !draftId) return;
if (typeof navigator !== "undefined" && !navigator.onLine) return;
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",
Expand Down
24 changes: 23 additions & 1 deletion src/hooks/useXlmUsdcRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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<number | null>(null);
const mounted = useRef(true);
const baseRateRef = useRef<number | null>(null);
const optionsRef = useRef(options);
optionsRef.current = options;

useEffect(() => {
mounted.current = true;
baseRateRef.current = null;

const load = async () => {
try {
Expand All @@ -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 {
Expand Down