diff --git a/apps/extension/package.json b/apps/extension/package.json index 7cabe6c..6cea530 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,7 +1,7 @@ { "name": "@order-wizard/extension", "private": true, - "version": "1.0.8", + "version": "1.0.9", "type": "module", "scripts": { "dev": "wxt", @@ -18,6 +18,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.20", + "@tanstack/react-virtual": "^3.13.24", "clsx": "^2.1.1", "exceljs": "^4.4.0", "jspdf": "^4.2.0", diff --git a/apps/extension/scripts/seed-orders.js b/apps/extension/scripts/seed-orders.js new file mode 100644 index 0000000..8c8dda0 --- /dev/null +++ b/apps/extension/scripts/seed-orders.js @@ -0,0 +1,48 @@ +// Paste this into the side panel's DevTools Console to seed fake orders. +// To open DevTools on the side panel: right-click inside the panel → "Inspect". +// +// Usage: +// seed(500) // seed 500 orders +// seed(5000) // stress test +// clearOrders() // wipe all orders + +(() => { + const STATUSES = ['uncommented', 'commented', 'comment_revealed', 'reimbursed']; + const SAMPLE_NAMES = [ + 'Anker USB-C Charger 65W GaN III', + 'Logitech MX Master 3S Wireless Mouse', + 'Sony WH-1000XM5 Noise Cancelling Headphones', + 'Apple AirTag 4 Pack', + 'Kindle Paperwhite (11th Generation)', + 'Instant Pot Duo 7-in-1 Electric Pressure Cooker', + 'Dyson V15 Detect Cordless Vacuum', + 'LG 27" UltraGear Gaming Monitor', + 'Nespresso Vertuo Next Coffee Machine', + 'Bose QuietComfort Earbuds II', + ]; + + window.seed = async (n = 500) => { + const now = Date.now(); + const orders = Array.from({ length: n }, (_, i) => ({ + id: crypto.randomUUID(), + userId: 'local-dev', + orderNumber: `111-${String(1000000 + i).padStart(7, '0')}-${String(i % 10000).padStart(4, '0')}`, + productName: `${SAMPLE_NAMES[i % SAMPLE_NAMES.length]} (#${i + 1})`, + orderDate: new Date(now - i * 86_400_000).toISOString().slice(0, 10), + productImage: '', + price: `$${(Math.random() * 200 + 5).toFixed(2)}`, + status: STATUSES[i % STATUSES.length], + createdAt: new Date(now - i * 60_000).toISOString(), + updatedAt: new Date(now - i * 60_000).toISOString(), + })); + await chrome.storage.local.set({ orders }); + console.log(`Seeded ${n} orders. Reload the side panel to see them.`); + }; + + window.clearOrders = async () => { + await chrome.storage.local.remove('orders'); + console.log('Cleared orders. Reload the side panel.'); + }; + + console.log('Seed helpers loaded: seed(n), clearOrders()'); +})(); diff --git a/apps/extension/src/components/OrderCard.tsx b/apps/extension/src/components/OrderCard.tsx index 2a41484..2571669 100644 --- a/apps/extension/src/components/OrderCard.tsx +++ b/apps/extension/src/components/OrderCard.tsx @@ -9,6 +9,7 @@ import { Trash2, ExternalLink, } from 'lucide-react'; +import { memo } from 'react'; import { OrderStatus, ORDER_STATUS_LABELS, type Order } from '@/types'; import { cn } from '@/lib'; import { Card, CardTitle } from './ui/card'; @@ -83,7 +84,7 @@ interface OrderCardProps { onImageError: (orderId: string) => void; } -export function OrderCard({ +function OrderCardImpl({ order, isSelected, hasImageError, @@ -248,3 +249,5 @@ export function OrderCard({ ); } + +export const OrderCard = memo(OrderCardImpl); diff --git a/apps/extension/src/components/OrderTable.tsx b/apps/extension/src/components/OrderTable.tsx index d6511b7..98b7a60 100644 --- a/apps/extension/src/components/OrderTable.tsx +++ b/apps/extension/src/components/OrderTable.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useDeleteOrders, useOrders, useUpdateOrderStatus } from '@/hooks/useOrders'; import type { OrderStatus } from '@/types'; import type { OrderSortOption, StatusFilter } from '@/utils/orderFilters'; @@ -16,6 +17,8 @@ export function OrderTable() { const { data: orders = [], isLoading } = useOrders(); const updateStatusMutation = useUpdateOrderStatus(); const deleteOrdersMutation = useDeleteOrders(); + const { mutate: mutateStatus } = updateStatusMutation; + const { mutateAsync: mutateDelete, isPending: isDeleting } = deleteOrdersMutation; // UI state (local - no need for global store) const [searchQuery, setSearchQuery] = useState(''); @@ -25,15 +28,17 @@ export function OrderTable() { const [confirmData, setConfirmData] = useState(null); const [imageFailures, setImageFailures] = useState>(new Set()); - // Derived state + // Keep the input responsive; defer the heavy filter/sort pass to a low-priority render. + const deferredSearchQuery = useDeferredValue(searchQuery); + const displayOrders = useMemo( - () => filterAndSortOrders(orders, searchQuery, statusFilter, sortOption), - [orders, searchQuery, statusFilter, sortOption], + () => filterAndSortOrders(orders, deferredSearchQuery, statusFilter, sortOption), + [orders, deferredSearchQuery, statusFilter, sortOption], ); - // Clean up selected IDs when orders change (remove IDs that no longer exist) + // Prune selected IDs only when the underlying order set changes, not on every search keystroke. useEffect(() => { - const orderIds = new Set(displayOrders.map((o) => o.id)); + const orderIds = new Set(orders.map((o) => o.id)); setSelectedIds((previous) => { const stillValid = [...previous].filter((id) => orderIds.has(id)); if (stillValid.length === previous.size) { @@ -41,17 +46,20 @@ export function OrderTable() { } return new Set(stillValid); }); - }, [displayOrders]); + }, [orders]); const selectedCount = selectedIds.size; const allSelected = displayOrders.length > 0 && selectedCount === displayOrders.length; const someSelected = selectedCount > 0 && selectedCount < displayOrders.length; - const toggleSelectAll = (checked: boolean) => { - setSelectedIds(checked ? new Set(displayOrders.map((order) => order.id)) : new Set()); - }; + const toggleSelectAll = useCallback( + (checked: boolean) => { + setSelectedIds(checked ? new Set(displayOrders.map((order) => order.id)) : new Set()); + }, + [displayOrders], + ); - const toggleSelect = (orderId: string) => { + const toggleSelect = useCallback((orderId: string) => { setSelectedIds((previous) => { const next = new Set(previous); if (next.has(orderId)) { @@ -61,27 +69,31 @@ export function OrderTable() { } return next; }); - }; - - const handleDeleteSelected = () => { - if (selectedCount === 0) return; - setConfirmData({ - type: 'bulk', - orderIds: Array.from(selectedIds), - message: `Delete ${selectedCount} selected order${selectedCount === 1 ? '' : 's'}?`, + }, []); + + const handleDeleteSelected = useCallback(() => { + setSelectedIds((current) => { + if (current.size === 0) return current; + const ids = Array.from(current); + setConfirmData({ + type: 'bulk', + orderIds: ids, + message: `Delete ${ids.length} selected order${ids.length === 1 ? '' : 's'}?`, + }); + return current; }); - }; + }, []); - const handleDeleteSingle = (orderId: string) => { + const handleDeleteSingle = useCallback((orderId: string) => { setConfirmData({ type: 'single', orderId, message: 'Delete this order?' }); - }; + }, []); - const handleConfirmDelete = async () => { + const handleConfirmDelete = useCallback(async () => { if (!confirmData) return; const idsToDelete = confirmData.type === 'bulk' ? confirmData.orderIds : [confirmData.orderId]; - await deleteOrdersMutation.mutateAsync(idsToDelete); + await mutateDelete(idsToDelete); setSelectedIds((prev) => { const next = new Set(prev); for (const id of idsToDelete) { @@ -90,29 +102,47 @@ export function OrderTable() { return next; }); setConfirmData(null); - }; + }, [confirmData, mutateDelete]); - const handleCancelDelete = () => { - if (deleteOrdersMutation.isPending) return; + const handleCancelDelete = useCallback(() => { + if (isDeleting) return; setConfirmData(null); - }; + }, [isDeleting]); - const handleExport = (format: ExportFormat) => { - exportOrders(displayOrders, format); - }; + const handleExport = useCallback( + (format: ExportFormat) => { + exportOrders(displayOrders, format); + }, + [displayOrders], + ); - const handleImageError = (orderId: string) => { + const handleImageError = useCallback((orderId: string) => { setImageFailures((previous) => { if (previous.has(orderId)) return previous; const next = new Set(previous); next.add(orderId); return next; }); - }; + }, []); + + const handleStatusChange = useCallback( + (orderId: string, status: OrderStatus) => { + mutateStatus({ id: orderId, status }); + }, + [mutateStatus], + ); + + const handleClearSearch = useCallback(() => setSearchQuery(''), []); - const handleStatusChange = (orderId: string, status: OrderStatus) => { - updateStatusMutation.mutate({ id: orderId, status }); - }; + const scrollParentRef = useRef(null); + const virtualizer = useVirtualizer({ + count: displayOrders.length, + getScrollElement: () => scrollParentRef.current, + estimateSize: () => 220, + overscan: 6, + gap: 14, + getItemKey: (index) => displayOrders[index]?.id ?? index, + }); if (isLoading) { return ; @@ -145,23 +175,48 @@ export function OrderTable() { /> -
+
{displayOrders.length === 0 ? ( - setSearchQuery('')} /> + ) : ( -
- {displayOrders.map((order) => ( - - ))} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const order = displayOrders[virtualRow.index]; + if (!order) return null; + return ( +
+ +
+ ); + })}
)}
@@ -169,7 +224,7 @@ export function OrderTable() { {confirmData ? ( void handleConfirmDelete()} onCancel={handleCancelDelete} /> diff --git a/apps/extension/src/config/oauth.ts b/apps/extension/src/config/oauth.ts index 2e03b4c..82ce76f 100644 --- a/apps/extension/src/config/oauth.ts +++ b/apps/extension/src/config/oauth.ts @@ -1,21 +1,30 @@ import type * as oauth from 'oauth4webapi'; import { cognitoAuthority, cognitoClientId, cognitoDomain } from './env'; -const issuer = new URL(cognitoAuthority); +// Tolerate missing env at module load so the side panel still boots without OAuth +// configured. Real sign-in paths call assertOAuthConfigured() and surface a clear error. +function assertOAuthConfigured(): void { + if (!cognitoAuthority || !cognitoClientId || !cognitoDomain) { + throw new Error( + 'OAuth is not configured. Set VITE_COGNITO_AUTHORITY, VITE_COGNITO_CLIENT_ID, and VITE_COGNITO_DOMAIN in apps/extension/.env.', + ); + } +} export const authorizationServer: oauth.AuthorizationServer = { - issuer: issuer.href, - authorization_endpoint: `${cognitoDomain}/oauth2/authorize`, - token_endpoint: `${cognitoDomain}/oauth2/token`, - end_session_endpoint: `${cognitoDomain}/logout`, + issuer: cognitoAuthority ?? '', + authorization_endpoint: cognitoDomain ? `${cognitoDomain}/oauth2/authorize` : '', + token_endpoint: cognitoDomain ? `${cognitoDomain}/oauth2/token` : '', + end_session_endpoint: cognitoDomain ? `${cognitoDomain}/logout` : '', }; export const oauthClient: oauth.Client = { - client_id: cognitoClientId, + client_id: cognitoClientId ?? '', token_endpoint_auth_method: 'none', }; export function buildAuthorizationUrl(codeChallenge: string): URL { + assertOAuthConfigured(); const redirectUri = chrome.identity.getRedirectURL(); const authUrl = new URL(authorizationServer.authorization_endpoint as string); @@ -31,6 +40,7 @@ export function buildAuthorizationUrl(codeChallenge: string): URL { } export function buildLogoutUrl(): string { + assertOAuthConfigured(); const redirectUri = chrome.identity.getRedirectURL(); return `${cognitoDomain}/logout?client_id=${cognitoClientId}&logout_uri=${encodeURIComponent(redirectUri)}`; } diff --git a/apps/extension/src/contexts/AuthContext.tsx b/apps/extension/src/contexts/AuthContext.tsx index 1abc650..3514145 100644 --- a/apps/extension/src/contexts/AuthContext.tsx +++ b/apps/extension/src/contexts/AuthContext.tsx @@ -58,12 +58,13 @@ export function AuthProvider({ children }: AuthProviderProps) { saveCurrentUserToStorage(newUser); }, []); - // Update API repository token when user changes + // Update API repository token when the access token actually changes. + const accessToken = user?.access_token ?? null; useEffect(() => { if (apiRepository) { - apiRepository.setAccessToken(user?.access_token ?? null); + apiRepository.setAccessToken(accessToken); } - }, [user]); + }, [accessToken]); // Refresh token using refresh_token grant const refreshAccessToken = useCallback(async (currentUser: AuthUser): Promise => { diff --git a/apps/extension/src/hooks/useOrders.ts b/apps/extension/src/hooks/useOrders.ts index e2751be..cb65714 100644 --- a/apps/extension/src/hooks/useOrders.ts +++ b/apps/extension/src/hooks/useOrders.ts @@ -77,18 +77,15 @@ export function useDeleteOrders() { return useMutation({ mutationFn: async (ids: string[]) => { + if (ids.length === 0) return; const now = new Date().toISOString(); - // Soft delete locally (same for logged in or not) - for (const id of ids) { - await localRepository.update(id, { deletedAt: now, updatedAt: now }); + // Single read-modify-write pass for the whole batch. + const updated = await localRepository.updateMany(ids, { deletedAt: now, updatedAt: now }); - // Queue for cloud sync if authenticated - if (isAuthenticated && user) { - const order = await localRepository.getById(id); - if (order) { - syncQueue.add({ type: 'delete', orderId: order.id, orderNumber: order.orderNumber }); - } + if (isAuthenticated && user) { + for (const order of updated) { + syncQueue.add({ type: 'delete', orderId: order.id, orderNumber: order.orderNumber }); } } }, diff --git a/apps/extension/src/repositories/LocalStorageRepository.ts b/apps/extension/src/repositories/LocalStorageRepository.ts index 96b3697..620d788 100644 --- a/apps/extension/src/repositories/LocalStorageRepository.ts +++ b/apps/extension/src/repositories/LocalStorageRepository.ts @@ -49,6 +49,21 @@ export class LocalStorageRepository { await this.saveAllOrders(orders); } + async updateMany(ids: string[], updates: Partial): Promise { + if (ids.length === 0) return []; + const idSet = new Set(ids); + const orders = await this.getAllOrders(); + const updated: Order[] = []; + const next = orders.map((order) => { + if (!idSet.has(order.id)) return order; + const merged = { ...order, ...updates }; + updated.push(merged); + return merged; + }); + await this.saveAllOrders(next); + return updated; + } + async delete(id: string): Promise { const orders = await this.getAllOrders(); const filtered = orders.filter((order) => order.id !== id); diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 2026a5d..2b2d909 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server" -version = "1.0.8" +version = "1.0.9" edition = "2021" [dependencies] diff --git a/bun.lock b/bun.lock index 727cc20..f7993f9 100644 --- a/bun.lock +++ b/bun.lock @@ -7,9 +7,10 @@ }, "apps/extension": { "name": "@order-wizard/extension", - "version": "1.0.4", + "version": "1.0.8", "dependencies": { "@tanstack/react-query": "^5.90.20", + "@tanstack/react-virtual": "^3.13.24", "clsx": "^2.1.1", "exceljs": "^4.4.0", "jspdf": "^4.2.0", @@ -277,6 +278,10 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/package.json b/package.json index 845d774..6783fd2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,9 @@ { "name": "order-wizard", - "version": "1.0.8", + "version": "1.0.9", + "packageManager": "bun@1.1.38", "private": true, - "workspaces": ["apps/*"], - "packageManager": "bun@1.1.38" + "workspaces": [ + "apps/*" + ] }