Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@order-wizard/extension",
"private": true,
"version": "1.0.8",
"version": "1.0.9",
"type": "module",
"scripts": {
"dev": "wxt",
Expand All @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions apps/extension/scripts/seed-orders.js
Original file line number Diff line number Diff line change
@@ -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()');
})();
5 changes: 4 additions & 1 deletion apps/extension/src/components/OrderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,7 +84,7 @@ interface OrderCardProps {
onImageError: (orderId: string) => void;
}

export function OrderCard({
function OrderCardImpl({
order,
isSelected,
hasImageError,
Expand Down Expand Up @@ -248,3 +249,5 @@ export function OrderCard({
</Card>
);
}

export const OrderCard = memo(OrderCardImpl);
159 changes: 107 additions & 52 deletions apps/extension/src/components/OrderTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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('');
Expand All @@ -25,33 +28,38 @@ export function OrderTable() {
const [confirmData, setConfirmData] = useState<ConfirmData | null>(null);
const [imageFailures, setImageFailures] = useState<Set<string>>(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) {
return previous;
}
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<string>());
};
const toggleSelectAll = useCallback(
(checked: boolean) => {
setSelectedIds(checked ? new Set(displayOrders.map((order) => order.id)) : new Set<string>());
},
[displayOrders],
);

const toggleSelect = (orderId: string) => {
const toggleSelect = useCallback((orderId: string) => {
setSelectedIds((previous) => {
const next = new Set(previous);
if (next.has(orderId)) {
Expand All @@ -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) {
Expand All @@ -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<HTMLDivElement>(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 <OrderTableLoading />;
Expand Down Expand Up @@ -145,31 +175,56 @@ export function OrderTable() {
/>
</div>

<div className="flex-1 overflow-y-auto px-3 pb-3 pt-3 sm:px-4 sm:pb-4 sm:pt-4">
<div
ref={scrollParentRef}
className="flex-1 overflow-y-auto px-3 pb-3 pt-3 sm:px-4 sm:pb-4 sm:pt-4"
>
{displayOrders.length === 0 ? (
<OrderTableNoResults searchQuery={searchQuery} onClearSearch={() => setSearchQuery('')} />
<OrderTableNoResults searchQuery={deferredSearchQuery} onClearSearch={handleClearSearch} />
) : (
<div className="flex flex-col gap-3.5">
{displayOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
isSelected={selectedIds.has(order.id)}
hasImageError={imageFailures.has(order.id)}
onToggleSelect={toggleSelect}
onStatusChange={handleStatusChange}
onDelete={handleDeleteSingle}
onImageError={handleImageError}
/>
))}
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
width: '100%',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const order = displayOrders[virtualRow.index];
if (!order) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<OrderCard
order={order}
isSelected={selectedIds.has(order.id)}
hasImageError={imageFailures.has(order.id)}
onToggleSelect={toggleSelect}
onStatusChange={handleStatusChange}
onDelete={handleDeleteSingle}
onImageError={handleImageError}
/>
</div>
);
})}
</div>
)}
</div>

{confirmData ? (
<DeleteConfirmModal
confirmData={confirmData}
isDeleting={deleteOrdersMutation.isPending}
isDeleting={isDeleting}
onConfirm={() => void handleConfirmDelete()}
onCancel={handleCancelDelete}
/>
Expand Down
22 changes: 16 additions & 6 deletions apps/extension/src/config/oauth.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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)}`;
}
7 changes: 4 additions & 3 deletions apps/extension/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthUser | null> => {
Expand Down
Loading
Loading