@@ -6,6 +6,7 @@ import { useParams } from 'next/navigation';
66import { useTranslations } from 'next-intl' ;
77import { useEffect , useState } from 'react' ;
88
9+ import { Loader } from '@/components/shared/Loader' ;
910import { useCart } from '@/components/shop/CartProvider' ;
1011import { Link , useRouter } from '@/i18n/routing' ;
1112import { formatMoney } from '@/lib/shop/currency' ;
@@ -54,6 +55,14 @@ const SHOP_HERO_CTA = cn(
5455 'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
5556) ;
5657
58+ const ORDERS_LINK = cn ( SHOP_LINK_BASE , SHOP_LINK_MD , SHOP_FOCUS ) ;
59+
60+ const ORDERS_COUNT_BADGE = cn (
61+ 'border-border bg-muted/40 text-foreground inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold tabular-nums'
62+ ) ;
63+
64+ const ORDERS_CARD = cn ( 'border-border rounded-md border p-4' ) ;
65+
5766type Props = {
5867 stripeEnabled : boolean ;
5968 monobankEnabled : boolean ;
@@ -75,14 +84,27 @@ function resolveInitialProvider(args: {
7584 return 'stripe' ;
7685}
7786
87+ type OrdersSummaryState = {
88+ count : number ;
89+ latestOrderId : string | null ;
90+ } ;
91+
7892export default function CartPage ( { stripeEnabled, monobankEnabled } : Props ) {
7993 const { cart, updateQuantity, removeFromCart } = useCart ( ) ;
8094 const router = useRouter ( ) ;
8195 const t = useTranslations ( 'shop.cart' ) ;
96+ const tOrders = useTranslations ( 'shop.orders' ) ;
8297 const tColors = useTranslations ( 'shop.catalog.colors' ) ;
98+
8399 const [ isCheckingOut , setIsCheckingOut ] = useState ( false ) ;
84100 const [ checkoutError , setCheckoutError ] = useState < string | null > ( null ) ;
85101 const [ createdOrderId , setCreatedOrderId ] = useState < string | null > ( null ) ;
102+
103+ const [ ordersSummary , setOrdersSummary ] = useState < OrdersSummaryState | null > (
104+ null
105+ ) ;
106+ const [ isOrdersLoading , setIsOrdersLoading ] = useState ( false ) ;
107+
86108 const [ selectedProvider , setSelectedProvider ] = useState < CheckoutProvider > (
87109 ( ) =>
88110 resolveInitialProvider ( {
@@ -91,6 +113,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
91113 currency : cart ?. summary ?. currency ,
92114 } )
93115 ) ;
116+ const [ isClientReady , setIsClientReady ] = useState ( false ) ;
117+
118+ useEffect ( ( ) => {
119+ setIsClientReady ( true ) ;
120+ } , [ ] ) ;
94121
95122 const params = useParams < { locale ?: string } > ( ) ;
96123 const locale = params . locale ?? 'en' ;
@@ -110,6 +137,157 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
110137 }
111138 } , [ canUseMonobank , canUseStripe , selectedProvider ] ) ;
112139
140+ useEffect ( ( ) => {
141+ let cancelled = false ;
142+ const controller = new AbortController ( ) ;
143+
144+ async function loadOrdersSummary ( ) {
145+ setIsOrdersLoading ( true ) ;
146+
147+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , 2500 ) ;
148+
149+ try {
150+ const res = await fetch ( '/api/shop/orders' , {
151+ method : 'GET' ,
152+ headers : { Accept : 'application/json' } ,
153+ cache : 'no-store' ,
154+ signal : controller . signal ,
155+ } ) ;
156+
157+ if ( res . status === 401 || res . status === 403 ) {
158+ if ( ! cancelled ) {
159+ setOrdersSummary ( null ) ;
160+ }
161+ return ;
162+ }
163+
164+ const devWarn = ( message : string , meta : Record < string , unknown > ) => {
165+ if ( process . env . NODE_ENV === 'production' ) return ;
166+
167+ const g = globalThis as unknown as {
168+ __DEVLOVERS_SHOP_DEBUG_LOGS__ ?: Array < {
169+ level : 'warn' ;
170+ message : string ;
171+ meta : Record < string , unknown > ;
172+ ts : number ;
173+ } > ;
174+ } ;
175+
176+ if ( ! g . __DEVLOVERS_SHOP_DEBUG_LOGS__ ) {
177+ g . __DEVLOVERS_SHOP_DEBUG_LOGS__ = [ ] ;
178+ }
179+
180+ g . __DEVLOVERS_SHOP_DEBUG_LOGS__ . push ( {
181+ level : 'warn' ,
182+ message,
183+ meta,
184+ ts : Date . now ( ) ,
185+ } ) ;
186+ } ;
187+
188+ let rawBody : string | null = null ;
189+ let data : unknown = null ;
190+ let parseError : unknown = null ;
191+
192+ try {
193+ rawBody = await res . text ( ) ;
194+ if ( rawBody && rawBody . trim ( ) . length > 0 ) {
195+ try {
196+ data = JSON . parse ( rawBody ) as unknown ;
197+ } catch ( err ) {
198+ parseError = err ;
199+ data = null ;
200+ }
201+ }
202+ } catch ( err ) {
203+ parseError = err ;
204+ data = null ;
205+ }
206+
207+ const bodyPreview = rawBody ? rawBody . slice ( 0 , 500 ) : null ;
208+ const parseErrorMessage =
209+ parseError instanceof Error
210+ ? parseError . message
211+ : parseError
212+ ? String ( parseError )
213+ : null ;
214+
215+ if ( ! res . ok ) {
216+ devWarn ( '[shop.cart] orders summary fetch non-OK' , {
217+ status : res . status ,
218+ statusText : res . statusText ,
219+ bodyPreview,
220+ parseError : parseErrorMessage ,
221+ } ) ;
222+ return ;
223+ }
224+
225+ if ( ! data || typeof data !== 'object' ) {
226+ devWarn ( '[shop.cart] orders summary fetch invalid JSON' , {
227+ status : res . status ,
228+ statusText : res . statusText ,
229+ bodyType : data === null ? 'null' : typeof data ,
230+ bodyPreview,
231+ parseError : parseErrorMessage ,
232+ } ) ;
233+ return ;
234+ }
235+
236+ const maybe = data as {
237+ success ?: unknown ;
238+ orders ?: unknown ;
239+ totalCount ?: unknown ;
240+ } ;
241+
242+ if ( maybe . success !== true || ! Array . isArray ( maybe . orders ) ) {
243+ devWarn ( '[shop.cart] orders summary fetch unexpected shape' , {
244+ status : res . status ,
245+ statusText : res . statusText ,
246+ bodyPreview,
247+ } ) ;
248+ return ;
249+ }
250+
251+ const orders = maybe . orders as Array < { id ?: unknown } > ;
252+
253+ const totalCountRaw = maybe . totalCount ;
254+ const totalCountNum =
255+ typeof totalCountRaw === 'number'
256+ ? totalCountRaw
257+ : typeof totalCountRaw === 'string'
258+ ? Number ( totalCountRaw )
259+ : typeof totalCountRaw === 'bigint'
260+ ? Number ( totalCountRaw )
261+ : NaN ;
262+
263+ const count = Number . isFinite ( totalCountNum )
264+ ? Math . max ( 0 , Math . trunc ( totalCountNum ) )
265+ : orders . length ;
266+
267+ const latestOrderId =
268+ typeof orders [ 0 ] ?. id === 'string' ? orders [ 0 ] . id : null ;
269+
270+ if ( ! cancelled ) {
271+ setOrdersSummary ( { count, latestOrderId } ) ;
272+ }
273+ } catch {
274+ // ignore (timeout/network) — we just don't show summary
275+ } finally {
276+ clearTimeout ( timeoutId ) ;
277+ if ( ! cancelled ) {
278+ setIsOrdersLoading ( false ) ;
279+ }
280+ }
281+ }
282+
283+ void loadOrdersSummary ( ) ;
284+
285+ return ( ) => {
286+ cancelled = true ;
287+ controller . abort ( ) ;
288+ } ;
289+ } , [ ] ) ;
290+
113291 const translateColor = ( color : string | null | undefined ) : string | null => {
114292 if ( ! color ) return null ;
115293 const colorSlug = color . toLowerCase ( ) ;
@@ -228,6 +406,62 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
228406 }
229407 }
230408
409+ const ordersCard = ordersSummary ? (
410+ < div className = { ORDERS_CARD } >
411+ < div className = "flex items-center justify-between gap-3" >
412+ < Link href = "/shop/orders" className = { ORDERS_LINK } >
413+ { tOrders ( 'title' ) }
414+ </ Link >
415+
416+ { isOrdersLoading ? (
417+ < Loader2
418+ className = "text-muted-foreground h-4 w-4 animate-spin"
419+ aria-hidden = "true"
420+ />
421+ ) : (
422+ < span className = { ORDERS_COUNT_BADGE } aria-live = "polite" >
423+ { ordersSummary . count }
424+ </ span >
425+ ) }
426+ </ div >
427+
428+ < p className = "text-muted-foreground mt-2 text-xs" >
429+ { tOrders ( 'subtitle' ) }
430+ </ p >
431+
432+ { ordersSummary . latestOrderId ? (
433+ < div className = "mt-2" >
434+ < Link
435+ href = { `/shop/orders/${ encodeURIComponent ( ordersSummary . latestOrderId ) } ` }
436+ className = { cn ( SHOP_LINK_BASE , SHOP_LINK_XS , SHOP_FOCUS ) }
437+ >
438+ { t ( 'checkout.goToOrder' ) }
439+ </ Link >
440+ </ div >
441+ ) : null }
442+ </ div >
443+ ) : null ;
444+ const loadingAnnouncement = ( ( ) => {
445+ try {
446+ return t ( 'loading' ) ;
447+ } catch {
448+ return 'Loading…' ;
449+ }
450+ } ) ( ) ;
451+
452+ if ( ! isClientReady ) {
453+ return (
454+ < main className = "mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" >
455+ < div className = "flex flex-col items-center justify-center gap-4" >
456+ < Loader size = { 160 } className = "opacity-90" />
457+ < span className = "sr-only" role = "status" aria-live = "polite" >
458+ { loadingAnnouncement }
459+ </ span >
460+ </ div >
461+ </ main >
462+ ) ;
463+ }
464+
231465 if ( cart . items . length === 0 ) {
232466 return (
233467 < main className = "mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8" >
@@ -262,6 +496,8 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
262496 < span className = { SHOP_CTA_INSET } aria-hidden = "true" />
263497 < span className = "relative z-10" > { t ( 'startShopping' ) } </ span >
264498 </ Link >
499+
500+ { ordersCard ? < div className = "mt-6" > { ordersCard } </ div > : null }
265501 </ div >
266502 </ div >
267503 </ main >
@@ -406,6 +642,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
406642 </ li >
407643 ) ) }
408644 </ ul >
645+ { ordersCard ? < div className = "mt-6" > { ordersCard } </ div > : null }
409646 </ section >
410647
411648 < aside
@@ -550,12 +787,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
550787 />
551788 ) : null }
552789
553- { /* visible label stays stable to avoid wrapping/layout shift */ }
554790 < span className = "truncate whitespace-nowrap" >
555791 { t ( 'checkout.placeOrder' ) }
556792 </ span >
557793
558- { /* screen readers can still get the “placing” state */ }
559794 { isCheckingOut ? (
560795 < span className = "sr-only" > { t ( 'checkout.placing' ) } </ span >
561796 ) : null }
0 commit comments