1- import { useEffect , useState } from "react" ;
1+ import { useEffect , useMemo , useState } from "react" ;
22import "./AdminShopPage.css" ;
33
4+ function formatUsd ( value ) {
5+ return `$${ Number ( value ?? 0 ) . toFixed ( 2 ) } ` ;
6+ }
7+
8+ function formatDate ( value ) {
9+ if ( ! value ) return "Unknown date" ;
10+ return new Date ( value ) . toLocaleString ( ) ;
11+ }
12+
13+ function orderStatus ( order ) {
14+ if ( order . rejected ) return "rejected" ;
15+ if ( order . fulfilled ) return "fulfilled" ;
16+ return "pending" ;
17+ }
18+
19+ function orderUsdTotals ( order ) {
20+ const quantity = Number ( order . quantity ?? 1 ) ;
21+ const shipping = Number ( order . shippingTaxUsd ?? 0 ) ;
22+ const itemUsd = order . itemPriceUsd != null ? Number ( order . itemPriceUsd ) : Number ( order . itemBricks ?? 0 ) / 10 ;
23+ const itemsOnly = itemUsd * quantity ;
24+ return {
25+ itemsOnly,
26+ shipping,
27+ total : itemsOnly + shipping ,
28+ } ;
29+ }
30+
31+ function groupLabel ( groupBy , key ) {
32+ if ( groupBy === "user" ) return `Customer: ${ key } ` ;
33+ if ( groupBy === "item" ) return `Item: ${ key } ` ;
34+ return "Flat list" ;
35+ }
36+
437export function AdminShopOrdersPage ( ) {
538 const [ orders , setOrders ] = useState ( [ ] ) ;
639 const [ loading , setLoading ] = useState ( true ) ;
740 const [ error , setError ] = useState ( "" ) ;
41+ const [ statusFilter , setStatusFilter ] = useState ( "pending" ) ;
42+ const [ groupBy , setGroupBy ] = useState ( "flat" ) ;
43+ const [ sortOrder , setSortOrder ] = useState ( "newest" ) ;
844
945 useEffect ( ( ) => {
1046 async function loadOrders ( ) {
@@ -40,16 +76,48 @@ export function AdminShopOrdersPage() {
4076 }
4177 }
4278
43- const groupedOrders = orders . reduce ( ( acc , order ) => {
44- const key = order . email ;
45- if ( ! acc [ key ] ) {
46- acc [ key ] = [ ] ;
79+ async function rejectOrder ( order ) {
80+ const ok = window . confirm ( `Reject order #${ order . id } and refund ${ Number ( order . totalBricks ?? 0 ) } bricks?` ) ;
81+ if ( ! ok ) return ;
82+
83+ try {
84+ const response = await fetch ( `/api/admin/shop/orders/${ order . id } /reject` , {
85+ method : "POST" ,
86+ credentials : "include" ,
87+ } ) ;
88+ const data = await response . json ( ) ;
89+ if ( ! response . ok ) throw new Error ( data . error || "Failed to reject order." ) ;
90+
91+ setOrders ( ( prev ) =>
92+ prev . map ( ( current ) => ( current . id === order . id ? { ...current , rejected : true } : current ) )
93+ ) ;
94+ } catch ( err ) {
95+ alert ( `Error: ${ err . message } ` ) ;
4796 }
48- acc [ key ] . push ( order ) ;
49- return acc ;
50- } , { } ) ;
97+ }
98+
99+ const visibleOrders = useMemo ( ( ) => {
100+ const filtered = orders . filter ( ( order ) => statusFilter === "all" || orderStatus ( order ) === statusFilter ) ;
101+ return [ ...filtered ] . sort ( ( a , b ) => {
102+ if ( sortOrder === "oldest" ) return new Date ( a . createdAt ) - new Date ( b . createdAt ) ;
103+ if ( sortOrder === "bricks-high" ) return Number ( b . totalBricks ?? 0 ) - Number ( a . totalBricks ?? 0 ) ;
104+ if ( sortOrder === "usd-high" ) return orderUsdTotals ( b ) . total - orderUsdTotals ( a ) . total ;
105+ return new Date ( b . createdAt ) - new Date ( a . createdAt ) ;
106+ } ) ;
107+ } , [ orders , sortOrder , statusFilter ] ) ;
108+
109+ const groupedOrders = useMemo ( ( ) => {
110+ if ( groupBy === "flat" ) return [ [ "all" , visibleOrders ] ] ;
51111
52- const sortedEmails = Object . keys ( groupedOrders ) . sort ( ) ;
112+ const groups = visibleOrders . reduce ( ( acc , order ) => {
113+ const key = groupBy === "item" ? order . itemName || "Unknown item" : order . email || "Unknown customer" ;
114+ if ( ! acc . has ( key ) ) acc . set ( key , [ ] ) ;
115+ acc . get ( key ) . push ( order ) ;
116+ return acc ;
117+ } , new Map ( ) ) ;
118+
119+ return [ ...groups . entries ( ) ] . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) ) ;
120+ } , [ groupBy , visibleOrders ] ) ;
53121
54122 return (
55123 < main className = "admin-shop-page" >
@@ -65,53 +133,95 @@ export function AdminShopOrdersPage() {
65133 { ! loading && ! error && orders . length === 0 && < p > No orders yet.</ p > }
66134
67135 { ! loading && ! error && orders . length > 0 && (
68- < div className = "admin-shop-orders" >
69- { sortedEmails . map ( ( email ) => (
70- < div key = { email } className = "admin-shop-order-group" >
71- < h2 > { email } </ h2 >
72- < table className = "admin-shop-orders-table" >
73- < thead >
74- < tr >
75- < th > Item</ th >
76- < th > Quantity</ th >
77- < th > Total Cost</ th >
78- < th > Date</ th >
79- < th > Status</ th >
80- < th > Action</ th >
81- </ tr >
82- </ thead >
83- < tbody >
84- { groupedOrders [ email ] . map ( ( order ) => (
85- < tr key = { order . id } className = { order . fulfilled ? "fulfilled" : "" } >
86- < td > { order . itemName } </ td >
87- < td className = "text-center" > { order . quantity } </ td >
88- < td className = "text-right" >
89- { order . totalbricks } bricks
90- { order . shippingTaxUsd ? ` (+$${ order . shippingTaxUsd } )` : "" }
91- </ td >
92- < td > { new Date ( order . createdAt ) . toLocaleDateString ( ) } </ td >
93- < td className = "text-center" >
94- < span className = { `status-badge ${ order . fulfilled ? "fulfilled" : "pending" } ` } >
95- { order . fulfilled ? "Fulfilled" : "Pending" }
96- </ span >
97- </ td >
98- < td className = "text-center" >
99- { ! order . fulfilled && (
100- < button
101- className = "admin-shop-fulfill-btn"
102- onClick = { ( ) => markAsFulfilled ( order . id ) }
103- >
104- Mark Fulfilled
136+ < >
137+ < div className = "admin-shop-order-controls" >
138+ < label >
139+ Status
140+ < select value = { statusFilter } onChange = { ( event ) => setStatusFilter ( event . target . value ) } >
141+ < option value = "pending" > Pending queue</ option >
142+ < option value = "fulfilled" > Fulfilled</ option >
143+ < option value = "rejected" > Rejected/refunded</ option >
144+ < option value = "all" > All orders</ option >
145+ </ select >
146+ </ label >
147+ < label >
148+ Group by
149+ < select value = { groupBy } onChange = { ( event ) => setGroupBy ( event . target . value ) } >
150+ < option value = "flat" > Flat list</ option >
151+ < option value = "user" > Customer</ option >
152+ < option value = "item" > Item</ option >
153+ </ select >
154+ </ label >
155+ < label >
156+ Queue order
157+ < select value = { sortOrder } onChange = { ( event ) => setSortOrder ( event . target . value ) } >
158+ < option value = "newest" > Newest first</ option >
159+ < option value = "oldest" > Oldest first</ option >
160+ < option value = "bricks-high" > Most bricks first</ option >
161+ < option value = "usd-high" > Highest $ first</ option >
162+ </ select >
163+ </ label >
164+ < span className = "admin-shop-order-count" >
165+ { visibleOrders . length } cards · { orders . length } DB rows
166+ </ span >
167+ </ div >
168+
169+ { visibleOrders . length === 0 ? < p > No orders match this view.</ p > : null }
170+
171+ < div className = "admin-shop-orders" >
172+ { groupedOrders . map ( ( [ key , groupOrders ] ) => (
173+ < section key = { key } className = "admin-shop-order-group" >
174+ { groupBy !== "flat" ? < h2 > { groupLabel ( groupBy , key ) } </ h2 > : null }
175+ < div className = "admin-shop-order-cards" >
176+ { groupOrders . map ( ( order ) => {
177+ const usd = orderUsdTotals ( order ) ;
178+ const status = orderStatus ( order ) ;
179+ const isActionable = status === "pending" ;
180+ return (
181+ < article key = { order . id } className = { `admin-shop-order-card is-${ status } ` } >
182+ < div className = "admin-shop-order-card-main" >
183+ < span className = { `status-badge ${ status } ` } > { status } </ span >
184+ < h2 > { order . itemName || "Unknown item" } </ h2 >
185+ < p >
186+ Qty: { order . quantity } · Total: { Number ( order . totalBricks ?? 0 ) } bricks
187+ </ p >
188+
189+ < div className = "admin-shop-order-money" >
190+ < span > Total: { formatUsd ( usd . total ) } </ span >
191+ < span > Items-only: { formatUsd ( usd . itemsOnly ) } </ span >
192+ < span > Shipping/tax: { formatUsd ( usd . shipping ) } </ span >
193+ < span > Item price: { formatUsd ( order . itemPriceUsd ?? Number ( order . itemBricks ?? 0 ) / 10 ) } </ span >
194+ </ div >
195+
196+ < div className = "admin-shop-order-customer" >
197+ < strong > Customer</ strong >
198+ < span > Name: { order . name || "Unknown" } </ span >
199+ < span > Email: { order . email || "Unknown" } </ span >
200+ < span > User ID: #{ order . userId } </ span >
201+ </ div >
202+
203+ < footer >
204+ < span > DB row: #{ order . id } </ span >
205+ < span > Created: { formatDate ( order . createdAt ) } </ span >
206+ </ footer >
207+ </ div >
208+
209+ < div className = "admin-shop-order-card-actions" >
210+ < button type = "button" disabled = { ! isActionable } onClick = { ( ) => markAsFulfilled ( order . id ) } >
211+ ✓ Mark fulfilled
212+ </ button >
213+ < button type = "button" disabled = { ! isActionable } className = "danger" onClick = { ( ) => rejectOrder ( order ) } >
214+ × Reject (refund bricks)
105215 </ button >
106- ) }
107- </ td >
108- </ tr >
109- ) ) }
110- </ tbody >
111- </ table >
112- </ div >
113- ) ) }
114- </ div >
216+ </ div >
217+ </ article >
218+ ) ;
219+ } ) }
220+ </ div >
221+ </ section >
222+ ) ) }
223+ </ div >
224+ </ >
115225 ) }
116226 </ section >
117227 </ main >
0 commit comments