@@ -4,12 +4,20 @@ import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar';
44import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page' ;
55
66import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle' ;
7+ import { AdminPagination } from '@/components/shop/admin/admin-pagination' ;
78import { db } from '@/db' ;
89import { products , productPrices } from '@/db/schema' ;
910import { formatMoney , resolveCurrencyFromLocale } from '@/lib/shop/currency' ;
1011import { fromDbMoney } from '@/lib/shop/money' ;
1112import { logWarn } from '@/lib/logging' ;
1213
14+ const PAGE_SIZE = 25 ;
15+
16+ function parsePage ( input : string | undefined ) : number {
17+ const n = Number . parseInt ( input ?? '1' , 10 ) ;
18+ return Number . isFinite ( n ) && n > 0 ? n : 1 ;
19+ }
20+
1321function formatDate ( value : Date | null , locale : string ) {
1422 if ( ! value ) return '-' ;
1523 return value . toLocaleDateString ( locale ) ;
@@ -19,7 +27,6 @@ function safeFromDbMoney(
1927 value : unknown ,
2028 ctx : { productId : string ; currency : string }
2129) : number | null {
22- // expected case for leftJoin: missing price row
2330 if ( value == null ) return null ;
2431
2532 try {
@@ -37,16 +44,21 @@ function safeFromDbMoney(
3744
3845export default async function AdminProductsPage ( {
3946 params,
47+ searchParams,
4048} : {
4149 params : Promise < { locale : string } > ;
50+ searchParams : Promise < { page ?: string } > ;
4251} ) {
4352 await guardShopAdminPage ( ) ;
4453 const { locale } = await params ;
54+ const sp = await searchParams ;
55+
56+ const page = parsePage ( sp . page ) ;
57+ const offset = ( page - 1 ) * PAGE_SIZE ;
4558
46- // currency policy: derived from locale
4759 const displayCurrency = resolveCurrencyFromLocale ( locale ) ;
4860
49- const rows = await db
61+ const all = await db
5062 . select ( {
5163 id : products . id ,
5264 title : products . title ,
@@ -58,7 +70,7 @@ export default async function AdminProductsPage({
5870 isActive : products . isActive ,
5971 isFeatured : products . isFeatured ,
6072 createdAt : products . createdAt ,
61- price : productPrices . price , // numeric (major) from product_prices
73+ price : productPrices . price ,
6274 } )
6375 . from ( products )
6476 . leftJoin (
@@ -68,16 +80,20 @@ export default async function AdminProductsPage({
6880 eq ( productPrices . currency , displayCurrency )
6981 )
7082 )
71- . orderBy ( desc ( products . createdAt ) ) ;
83+ // стабільне сортування (tie-breaker)
84+ . orderBy ( desc ( products . createdAt ) , desc ( products . id ) )
85+ . limit ( PAGE_SIZE + 1 )
86+ . offset ( offset ) ;
87+
88+ const hasNext = all . length > PAGE_SIZE ;
89+ const rows = all . slice ( 0 , PAGE_SIZE ) ;
7290
7391 return (
7492 < >
7593 < ShopAdminTopbar />
7694 < div className = "mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8" >
7795 < div className = "flex items-center justify-between" >
78- < h1 className = "text-2xl font-bold text-foreground" >
79- Admin · Products
80- </ h1 >
96+ < h1 className = "text-2xl font-bold text-foreground" > Admin · Products</ h1 >
8197 < Link
8298 href = "/shop/admin/products/new"
8399 className = "rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
@@ -90,39 +106,17 @@ export default async function AdminProductsPage({
90106 < table className = "w-full table-fixed divide-y divide-border text-sm" >
91107 < thead className = "bg-muted/50" >
92108 < tr >
93- < th className = "w-[20%] px-3 py-2 text-left font-semibold text-foreground" >
94- Title
95- </ th >
96- < th className = "w-[18%] px-3 py-2 text-left font-semibold text-foreground" >
97- Slug
98- </ th >
99- < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" >
100- Price
101- </ th >
102- < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" >
103- Category
104- </ th >
105- < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" >
106- Type
107- </ th >
108- < th className = "w-[5%] px-3 py-2 text-left font-semibold text-foreground" >
109- Stock
110- </ th >
111- < th className = "w-[5%] px-3 py-2 text-left font-semibold text-foreground" >
112- Badge
113- </ th >
114- < th className = "w-[5%] px-3 py-2 text-left font-semibold text-foreground" >
115- Active
116- </ th >
117- < th className = "w-[6%] px-3 py-2 text-left font-semibold text-foreground" >
118- Featured
119- </ th >
120- < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" >
121- Created
122- </ th >
123- < th className = "w-[9%] px-3 py-2 text-left font-semibold text-foreground" >
124- Actions
125- </ th >
109+ < th className = "w-[20%] px-3 py-2 text-left font-semibold text-foreground" > Title</ th >
110+ < th className = "w-[18%] px-3 py-2 text-left font-semibold text-foreground" > Slug</ th >
111+ < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" > Price</ th >
112+ < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" > Category</ th >
113+ < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" > Type</ th >
114+ < th className = "w-[5%] px-3 py-2 text-left font-semibold text-foreground" > Stock</ th >
115+ < th className = "w-[5%] px-3 py-2 text-left font-semibold text-foreground" > Badge</ th >
116+ < th className = "w-[5%] px-3 py-2 text-left font-semibold text-foreground" > Active</ th >
117+ < th className = "w-[6%] px-3 py-2 text-left font-semibold text-foreground" > Featured</ th >
118+ < th className = "w-[8%] px-3 py-2 text-left font-semibold text-foreground" > Created</ th >
119+ < th className = "w-[9%] px-3 py-2 text-left font-semibold text-foreground" > Actions</ th >
126120 </ tr >
127121 </ thead >
128122
@@ -136,59 +130,39 @@ export default async function AdminProductsPage({
136130 return (
137131 < tr key = { row . id } className = "hover:bg-muted/50" >
138132 < td className = "px-3 py-2 font-medium text-foreground max-w-0" >
139- < div className = "truncate" title = { row . title } >
140- { row . title }
141- </ div >
133+ < div className = "truncate" title = { row . title } > { row . title } </ div >
142134 </ td >
143135
144136 < td className = "px-3 py-2 text-muted-foreground max-w-0" >
145- < div className = "truncate" title = { row . slug } >
146- { row . slug }
147- </ div >
137+ < div className = "truncate" title = { row . slug } > { row . slug } </ div >
148138 </ td >
149139
150140 < td className = "px-3 py-2 text-foreground whitespace-nowrap" >
151- { priceMinor === null
152- ? '-'
153- : formatMoney ( priceMinor , displayCurrency , locale ) }
141+ { priceMinor === null ? '-' : formatMoney ( priceMinor , displayCurrency , locale ) }
154142 </ td >
155143
156144 < td className = "px-3 py-2 text-muted-foreground max-w-0" >
157- < div className = "truncate" title = { row . category ?? '-' } >
158- { row . category ?? '-' }
159- </ div >
145+ < div className = "truncate" title = { row . category ?? '-' } > { row . category ?? '-' } </ div >
160146 </ td >
161147
162148 < td className = "px-3 py-2 text-muted-foreground max-w-0" >
163- < div className = "truncate" title = { row . type ?? '-' } >
164- { row . type ?? '-' }
165- </ div >
149+ < div className = "truncate" title = { row . type ?? '-' } > { row . type ?? '-' } </ div >
166150 </ td >
167151
168- < td className = "px-3 py-2 text-muted-foreground whitespace-nowrap" >
169- { row . stock }
170- </ td >
152+ < td className = "px-3 py-2 text-muted-foreground whitespace-nowrap" > { row . stock } </ td >
171153
172154 < td className = "px-3 py-2 text-muted-foreground whitespace-nowrap" >
173155 { row . badge === 'NONE' ? '-' : row . badge }
174156 </ td >
175157
176158 < td className = "px-3 py-2 whitespace-nowrap" >
177- < span
178- className = "inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground"
179- aria-label = { row . isActive ? 'Active' : 'Inactive' }
180- >
159+ < span className = "inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground" >
181160 { row . isActive ? 'Yes' : 'No' }
182161 </ span >
183162 </ td >
184163
185164 < td className = "px-3 py-2 whitespace-nowrap" >
186- < span
187- className = "inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground"
188- aria-label = {
189- row . isFeatured ? 'Featured' : 'Not featured'
190- }
191- >
165+ < span className = "inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground" >
192166 { row . isFeatured ? 'Yes' : 'No' }
193167 </ span >
194168 </ td >
@@ -211,17 +185,20 @@ export default async function AdminProductsPage({
211185 >
212186 Edit
213187 </ Link >
214- < AdminProductStatusToggle
215- id = { row . id }
216- initialIsActive = { row . isActive }
217- />
188+ < AdminProductStatusToggle id = { row . id } initialIsActive = { row . isActive } />
218189 </ div >
219190 </ td >
220191 </ tr >
221192 ) ;
222193 } ) }
223194 </ tbody >
224195 </ table >
196+
197+ < AdminPagination
198+ basePath = "/shop/admin/products"
199+ page = { page }
200+ hasNext = { hasNext }
201+ />
225202 </ div >
226203 </ div >
227204 </ >
0 commit comments