diff --git a/next.config.js b/next.config.js index 75f5d98..a01df76 100644 --- a/next.config.js +++ b/next.config.js @@ -18,6 +18,24 @@ const nextConfig = { }, async headers() { return [ + { + // Applied to every route; the more specific /embed/:id rule below + // overrides X-Frame-Options for that one path. + source: "/:path*", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), payment=(self)", + }, + ], + }, { source: "/sw.js", headers: [ diff --git a/src/app/api/folders/[id]/route.ts b/src/app/api/folders/[id]/route.ts new file mode 100644 index 0000000..46113c8 --- /dev/null +++ b/src/app/api/folders/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { FolderNameSchema, deleteFolder, getFolder, renameFolder } from "@/lib/folders"; + +/** PATCH /api/folders/:id — rename a folder. */ +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + if (!getFolder(params.id)) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }); + } + + const rawBody = await request.json(); + const parsed = FolderNameSchema.safeParse(rawBody); + + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid folder payload — expected { name: string }", + details: parsed.error.issues, + }, + { status: 422 } + ); + } + + const folder = renameFolder(params.id, parsed.data.name); + return NextResponse.json({ folder }, { status: 200 }); + } catch (error) { + console.error("Folder rename error:", error); + return NextResponse.json( + { + error: "Failed to rename folder", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + +/** DELETE /api/folders/:id — delete a folder and clear its membership. */ +export async function DELETE( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const removed = deleteFolder(params.id); + if (!removed) { + return NextResponse.json({ error: "Folder not found" }, { status: 404 }); + } + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + console.error("Folder delete error:", error); + return NextResponse.json( + { + error: "Failed to delete folder", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts new file mode 100644 index 0000000..0af393e --- /dev/null +++ b/src/app/api/folders/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + FolderNameSchema, + MAX_FOLDERS, + createFolder, + getMembershipMap, + listFolders, +} from "@/lib/folders"; + +/** + * GET /api/folders — every folder, plus the invoiceId → folderIds membership map. + * + * One request serves both the sidebar (the folder list) and list filtering + * (the membership map), so the folders page doesn't fan out a request per invoice. + */ +export async function GET(_request: NextRequest) { + try { + return NextResponse.json( + { folders: listFolders(), byInvoice: getMembershipMap() }, + { status: 200 } + ); + } catch (error) { + console.error("Folder list error:", error); + return NextResponse.json( + { + error: "Failed to list folders", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + +/** POST /api/folders — create a new named folder. */ +export async function POST(request: NextRequest) { + try { + if (listFolders().length >= MAX_FOLDERS) { + return NextResponse.json({ error: `Cannot exceed ${MAX_FOLDERS} folders` }, { status: 422 }); + } + + const rawBody = await request.json(); + const parsed = FolderNameSchema.safeParse(rawBody); + + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid folder payload — expected { name: string }", + details: parsed.error.issues, + }, + { status: 422 } + ); + } + + const folder = createFolder(parsed.data.name); + return NextResponse.json({ folder }, { status: 201 }); + } catch (error) { + console.error("Folder create error:", error); + return NextResponse.json( + { + error: "Failed to create folder", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/invoices/[id]/folders/route.ts b/src/app/api/invoices/[id]/folders/route.ts new file mode 100644 index 0000000..0dff979 --- /dev/null +++ b/src/app/api/invoices/[id]/folders/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { FolderMembershipSchema, getFoldersForInvoice, setFoldersForInvoice } from "@/lib/folders"; + +/** GET /api/invoices/:id/folders — folder ids this invoice currently belongs to. */ +export async function GET( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + return NextResponse.json( + { invoiceId: params.id, folderIds: getFoldersForInvoice(params.id) }, + { status: 200 } + ); + } catch (error) { + console.error("Invoice folder fetch error:", error); + return NextResponse.json( + { + error: "Failed to fetch invoice folders", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/invoices/:id/folders — replace the invoice's folder membership. + * + * The client sends the full desired list (both adds and removes go through + * here), matching `PATCH /api/invoices/:id/tags`. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const rawBody = await request.json(); + const parsed = FolderMembershipSchema.safeParse(rawBody); + + if (!parsed.success) { + return NextResponse.json( + { + error: "Invalid payload — expected { folderIds: string[] }", + details: parsed.error.issues, + }, + { status: 422 } + ); + } + + const folderIds = setFoldersForInvoice(params.id, parsed.data.folderIds); + + return NextResponse.json( + { success: true, invoiceId: params.id, folderIds }, + { status: 200 } + ); + } catch (error) { + console.error("Invoice folder save error:", error); + return NextResponse.json( + { + error: "Failed to save invoice folders", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/invoices/route.ts b/src/app/api/invoices/route.ts index c8959b4..de4f193 100644 --- a/src/app/api/invoices/route.ts +++ b/src/app/api/invoices/route.ts @@ -1,7 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { splitClient } from "@/lib/stellar"; - -const PAGE_SIZE = 20; +import { fetchInvoicesForAddress } from "@/lib/stellar/horizonServer"; /** * GET /api/invoices?cursor=&limit=20&publicKey=
&q= @@ -19,9 +17,9 @@ const PAGE_SIZE = 20; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const publicKey = searchParams.get("publicKey"); - const cursorParam = searchParams.get("cursor"); + const cursor = searchParams.get("cursor"); const limitParam = searchParams.get("limit"); - const q = searchParams.get("q")?.trim().toLowerCase() || ""; + const q = searchParams.get("q") ?? undefined; if (!publicKey) { return NextResponse.json( @@ -30,66 +28,12 @@ export async function GET(request: NextRequest) { ); } - const limit = Math.min( - Math.max(1, parseInt(limitParam ?? String(PAGE_SIZE), 10) || PAGE_SIZE), - 50, - ); - - // Determine starting invoice id (cursor is the last id we already returned) - const startId = cursorParam ? parseInt(cursorParam, 10) + 1 : 1; - - const results = []; - let lastCheckedId = startId - 1; - - for (let id = startId; results.length < limit; id++) { - // Safety cap — don't scan more than limit*10 ids in a single request - if (id > startId + limit * 10) break; - - lastCheckedId = id; - - try { - const inv = await splitClient.getInvoice(String(id)); - const mine = - inv.creator === publicKey || - inv.recipients.some((r) => r.address === publicKey); - - if (mine) { - if (q) { - const memo = (inv as any).memo as string | undefined; - const matchesQuery = - (inv.title || "").toLowerCase().startsWith(q) || - (memo || "").toLowerCase().startsWith(q); - if (matchesQuery) { - results.push(inv); - } - } else { - results.push(inv); - } - } - } catch { - // splitClient throws when invoice id does not exist — treat as end of list - return NextResponse.json( - { invoices: results, nextCursor: null }, - { - headers: { - "Cache-Control": "private, no-store", - }, - }, - ); - } - } - - // If we filled the page we don't know yet whether there are more — return the - // id of the last invoice we fetched so the client can continue from there. - const nextCursor = - results.length === limit ? String(results[results.length - 1].id) : null; + const limit = limitParam ? parseInt(limitParam, 10) : undefined; + const result = await fetchInvoicesForAddress(publicKey, { cursor, limit, q }); - return NextResponse.json( - { invoices: results, nextCursor }, - { - headers: { - "Cache-Control": "private, no-store", - }, + return NextResponse.json(result, { + headers: { + "Cache-Control": "private, no-store", }, - ); + }); } diff --git a/src/app/dashboard/folders/page.tsx b/src/app/dashboard/folders/page.tsx new file mode 100644 index 0000000..f78d02c --- /dev/null +++ b/src/app/dashboard/folders/page.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Suspense, useCallback, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { FolderPlus } from "lucide-react"; +import { useWalletContext } from "@/contexts/WalletContext"; +import { useInfiniteInvoices } from "@/hooks/useInfiniteInvoices"; +import { useInvoiceSelection } from "@/hooks/useInvoiceSelection"; +import { useFolders } from "@/hooks/useFolders"; +import { useFoldersStore } from "@/lib/stores/foldersStore"; +import FolderSidebar from "@/components/folders/FolderSidebar"; +import AssignFolderMenu from "@/components/folders/AssignFolderMenu"; +import InvoiceCard from "@/components/InvoiceCard"; +import InvoiceListSentinel from "@/components/InvoiceListSentinel"; +import { InvoiceListSkeleton } from "@/components/Skeleton"; + +export default function FoldersPage() { + return ( + }> + + + ); +} + +function FoldersPageInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + const selectedFolderId = searchParams.get("folderId"); + + const { address } = useWalletContext(); + const { invoices, isLoading, hasMore, loadMore, isFetchingMore } = useInfiniteInvoices(address); + + const { + folders, + foldersByInvoice, + createFolder, + renameFolder, + deleteFolder, + assignFolders, + } = useFolders(); + + const { sidebarCollapsed, toggleSidebarCollapsed } = useFoldersStore(); + + const { selectedIds, isSelecting, toggleSelecting, toggleInvoice, deselectAll, isSelected, selectedCount } = + useInvoiceSelection(); + + const [bulkMenuOpen, setBulkMenuOpen] = useState(false); + const [cardMenuInvoiceId, setCardMenuInvoiceId] = useState(null); + + const selectFolder = useCallback( + (folderId: string | null) => { + const sp = new URLSearchParams(searchParams.toString()); + if (folderId) sp.set("folderId", folderId); + else sp.delete("folderId"); + router.replace(`?${sp.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + const visibleInvoices = useMemo(() => { + if (!selectedFolderId) return invoices; + return invoices.filter((inv) => (foldersByInvoice[inv.id] ?? []).includes(selectedFolderId)); + }, [invoices, selectedFolderId, foldersByInvoice]); + + const counts = useMemo(() => { + const out: Record = {}; + for (const folder of folders) out[folder.id] = 0; + for (const inv of invoices) { + for (const folderId of foldersByInvoice[inv.id] ?? []) { + out[folderId] = (out[folderId] ?? 0) + 1; + } + } + out.all = invoices.length; + return out; + }, [invoices, folders, foldersByInvoice]); + + if (!address) { + return ( +
+

Connect your wallet to view your invoice folders.

+
+ ); + } + + return ( +
+ + +
+
+

+ {selectedFolderId + ? folders.find((f) => f.id === selectedFolderId)?.name ?? "Folder" + : "All Invoices"} +

+ +
+ + {isSelecting && selectedCount > 0 && ( +
+ {selectedCount} selected + + + {bulkMenuOpen && ( + setBulkMenuOpen(false)} + /> + )} +
+ )} + + {isLoading ? ( + + ) : visibleInvoices.length === 0 ? ( +

+ {selectedFolderId ? "No invoices in this folder yet." : "No invoices yet."} +

+ ) : ( +
+ {visibleInvoices.map((invoice) => ( +
+
+ {isSelecting && ( + toggleInvoice(invoice.id)} + className="mt-5 shrink-0" + aria-label={`Select invoice ${invoice.id}`} + /> + )} +
+ + + +
+ +
+ {cardMenuInvoiceId === invoice.id && ( +
+ setCardMenuInvoiceId(null)} + /> +
+ )} +
+ ))} + +
+ )} +
+
+ ); +} diff --git a/src/app/dashboard/invoices/loading.tsx b/src/app/dashboard/invoices/loading.tsx new file mode 100644 index 0000000..1fdb117 --- /dev/null +++ b/src/app/dashboard/invoices/loading.tsx @@ -0,0 +1,17 @@ +import { InvoiceListSkeleton } from "@/components/Skeleton"; + +/** + * Stream-level skeleton for /dashboard/invoices, shown while the RSC shell + * (page.tsx) is being server-rendered — before the page's own + * fallback for the invoice table even mounts. + */ +export default function InvoicesLoading() { + return ( +
+
+
+
+ +
+ ); +} diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx new file mode 100644 index 0000000..f65731b --- /dev/null +++ b/src/app/dashboard/invoices/page.tsx @@ -0,0 +1,70 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import InvoiceListControls from "@/components/invoices/InvoiceListControls"; +import InvoiceTable from "@/components/invoices/InvoiceTable"; +import { InvoiceListSkeleton } from "@/components/Skeleton"; +import { fetchInvoicesForAddress } from "@/lib/stellar/horizonServer"; +import { sortInvoices, type DashboardSortId } from "@/lib/dashboardFilters"; +import { matchesQuery, matchesStatuses, type InvoiceStatusFilter } from "@/lib/invoices/listFilters"; + +export const metadata: Metadata = { + robots: { index: false, follow: false }, + title: "Your Invoices — StellarSplit", +}; + +interface InvoicesPageProps { + searchParams: { + address?: string; + q?: string; + status?: string; + sort?: string; + }; +} + +/** + * Invoice list page — a Server Component that fetches data directly via the + * Horizon-backed `splitClient`, no client-side fetch waterfall. The shell + * (heading + controls) renders immediately; the invoice list itself streams + * in once the on-chain scan resolves, via the Suspense boundary below. + * + * The wallet address lives in the URL (?address=…) rather than a server + * session — Freighter connection state is inherently client-only, so + * InvoiceListControls syncs the connected address into the URL on mount, + * which re-triggers this server fetch with the right owner. + */ +export default async function InvoicesPage({ searchParams }: InvoicesPageProps) { + return ( +
+

Your Invoices

+ }> + + + }> + + +
+ ); +} + +async function InvoiceResults({ searchParams }: InvoicesPageProps): Promise { + const address = searchParams.address; + const q = searchParams.q ?? ""; + const sort = (searchParams.sort ?? "newest") as DashboardSortId; + const statuses = (searchParams.status ?? "").split(",").filter(Boolean) as InvoiceStatusFilter[]; + + if (!address) { + return ( +

+ Connect your wallet to view your invoices. +

+ ); + } + + const { invoices } = await fetchInvoicesForAddress(address, { limit: 50 }); + const filtered = invoices.filter( + (invoice) => matchesQuery(invoice, q) && matchesStatuses(invoice, statuses) + ); + const sorted = sortInvoices(filtered, sort); + + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 35c48d3..7b537a0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,6 +21,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher"; import { UserPreferencesProvider } from "@/context/UserPreferencesContext"; import { FiatRateProvider } from "@/hooks/useFiatRate"; import { ShortcutRegistryProvider } from "@/context/ShortcutRegistry"; +import { getNonce } from "@/lib/csp"; const themeBootstrap = ` (function () { @@ -105,12 +106,14 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { + const nonce = getNonce(); + return ( // dir="ltr" is set here as scaffold; I18nProvider will update it client-side when RTL locales (ar/he) are added -