From 0a76233d61cbb2a606335f43710443fa437e52b0 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Thu, 26 Mar 2026 10:01:28 -0700 Subject: [PATCH 01/12] (SP: 2) [SHOP] complete admin orders filtering, i18n fixes, and blog-aligned UI polish --- .../app/[locale]/admin/shop/orders/page.tsx | 245 +++++++++++++----- frontend/app/api/shop/admin/orders/route.ts | 31 ++- .../components/admin/shop/AdminPagination.tsx | 33 ++- frontend/db/queries/shop/admin-orders.ts | 25 +- .../tests/shop/admin-orders-messages.test.ts | 58 +++++ .../shop/admin-orders-page-filters.test.ts | 130 ++++++++++ .../shop/admin-orders-query-filters.test.ts | 218 ++++++++++++++++ .../shop/admin-orders-route-filters.test.ts | 146 +++++++++++ frontend/lib/validation/shop-admin-orders.ts | 108 ++++++++ frontend/messages/en.json | 12 +- frontend/messages/pl.json | 12 +- frontend/messages/uk.json | 12 +- 12 files changed, 952 insertions(+), 78 deletions(-) create mode 100644 frontend/lib/tests/shop/admin-orders-messages.test.ts create mode 100644 frontend/lib/tests/shop/admin-orders-page-filters.test.ts create mode 100644 frontend/lib/tests/shop/admin-orders-query-filters.test.ts create mode 100644 frontend/lib/tests/shop/admin-orders-route-filters.test.ts create mode 100644 frontend/lib/validation/shop-admin-orders.ts diff --git a/frontend/app/[locale]/admin/shop/orders/page.tsx b/frontend/app/[locale]/admin/shop/orders/page.tsx index bef658a2..b7689c71 100644 --- a/frontend/app/[locale]/admin/shop/orders/page.tsx +++ b/frontend/app/[locale]/admin/shop/orders/page.tsx @@ -12,6 +12,12 @@ import { resolveCurrencyFromLocale, } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; +import { paymentStatusValues } from '@/lib/shop/payments'; +import { + adminOrdersFilterInputSchema, + EMPTY_ADMIN_ORDERS_FILTERS, + normalizeAdminOrdersFilters, +} from '@/lib/validation/shop-admin-orders'; export const metadata: Metadata = { title: 'Admin Orders | DevLovers', @@ -19,6 +25,15 @@ export const metadata: Metadata = { }; const PAGE_SIZE = 25; +const TH_BASE = + 'px-3 py-2 text-left text-xs font-semibold text-foreground whitespace-nowrap'; +const TD_BASE = 'px-3 py-3 text-sm align-top'; +const FIELD_CLASS = + 'h-10 rounded-md border border-border bg-background px-3 text-sm text-foreground shadow-sm outline-none transition focus:border-foreground/40 focus:ring-2 focus:ring-foreground/10'; +const PRIMARY_BUTTON_CLASS = + 'inline-flex h-10 items-center justify-center rounded-md bg-foreground px-4 text-sm font-medium text-background transition-colors hover:bg-foreground/90'; +const SECONDARY_BUTTON_CLASS = + 'inline-flex h-10 items-center justify-center rounded-md border border-border px-4 text-sm font-medium text-foreground transition-colors hover:bg-secondary'; type AdminOrdersResult = Awaited>; type AdminOrderRow = AdminOrdersResult['items'][number]; @@ -36,7 +51,30 @@ function orderCurrency(order: AdminOrderRow, locale: string): CurrencyCode { function formatDate(value: Date | null | undefined, locale: string): string { if (!value) return '-'; - return value.toLocaleDateString(locale); + return new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + }).format(value); +} + +function formatPaymentStatusLabel(value: string): string { + return value + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function paymentStatusBadgeClass( + status: AdminOrderRow['paymentStatus'] +): string { + if (status === 'paid') return 'bg-emerald-500/10 text-emerald-500'; + if (status === 'failed' || status === 'refunded') { + return 'bg-rose-500/10 text-rose-500'; + } + if (status === 'needs_review') return 'bg-amber-500/10 text-amber-500'; + if (status === 'requires_payment') return 'bg-sky-500/10 text-sky-500'; + return 'bg-muted text-muted-foreground'; } export default async function AdminOrdersPage({ @@ -44,19 +82,33 @@ export default async function AdminOrdersPage({ searchParams, }: { params: Promise<{ locale: string }>; - searchParams: Promise<{ page?: string }>; + searchParams: Promise<{ + page?: string | string[]; + status?: string | string[]; + dateFrom?: string | string[]; + dateTo?: string | string[]; + }>; }) { const { locale } = await params; const sp = await searchParams; const t = await getTranslations('shop.admin.orders'); const csrfToken = issueCsrfToken('admin:orders:reconcile-stale'); - const page = parsePage(sp.page); + const page = parsePage(Array.isArray(sp.page) ? sp.page[0] : sp.page); const offset = (page - 1) * PAGE_SIZE; + const parsedFilters = adminOrdersFilterInputSchema.safeParse({ + status: sp.status, + dateFrom: sp.dateFrom, + dateTo: sp.dateTo, + }); + const filters = parsedFilters.success + ? normalizeAdminOrdersFilters(parsedFilters.data) + : EMPTY_ADMIN_ORDERS_FILTERS; const { items: all } = await getAdminOrdersPage({ limit: PAGE_SIZE + 1, offset, + ...filters, }); const hasNext = all.length > PAGE_SIZE; @@ -70,6 +122,7 @@ export default async function AdminOrdersPage({ id: order.id, createdAt: formatDate(order.createdAt, locale), paymentStatus: order.paymentStatus, + paymentStatusLabel: formatPaymentStatusLabel(order.paymentStatus), totalFormatted: totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale), itemCount: order.itemCount, @@ -81,33 +134,102 @@ export default async function AdminOrdersPage({ return (
-

- {t('title')} -

+
+

+ {t('title')} +

+

{t('subtitle')}

+
-
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {t('filters.reset')} + +
+
+ {/* Mobile cards */}
{viewModels.length === 0 ? ( -
+
{t('empty')}
) : ( @@ -115,16 +237,18 @@ export default async function AdminOrdersPage({ {viewModels.map(vm => (
  • {vm.createdAt}
    -
    - - {vm.paymentStatus} +
    + + {vm.paymentStatusLabel}
    @@ -170,7 +294,7 @@ export default async function AdminOrdersPage({
    {t('actions.view')} @@ -184,52 +308,31 @@ export default async function AdminOrdersPage({ {/* Desktop table */}
    -
    +
    - - - - - - - @@ -238,43 +341,58 @@ export default async function AdminOrdersPage({ {viewModels.length === 0 ? ( - ) : ( viewModels.map(vm => ( - - - - - - -
    {t('listCaption')}
    + {t('table.created')} + {t('table.status')} + {t('table.total')} + {t('table.items')} + {t('table.provider')} + {t('table.orderId')} + {t('table.actions')}
    + {t('empty')}
    + {vm.createdAt} - - {vm.paymentStatus} + + + {vm.paymentStatusLabel} + {vm.totalFormatted} + {vm.itemCount} + {vm.paymentProvider} + {vm.id} + {t('actions.view')} @@ -288,11 +406,16 @@ export default async function AdminOrdersPage({ -
    +
    diff --git a/frontend/app/api/shop/admin/orders/route.ts b/frontend/app/api/shop/admin/orders/route.ts index 155dbed1..3082ac52 100644 --- a/frontend/app/api/shop/admin/orders/route.ts +++ b/frontend/app/api/shop/admin/orders/route.ts @@ -13,6 +13,10 @@ import { import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + adminOrdersFilterInputSchema, + normalizeAdminOrdersFilters, +} from '@/lib/validation/shop-admin-orders'; export const runtime = 'nodejs'; @@ -70,12 +74,24 @@ export async function GET(request: NextRequest) { limit: request.nextUrl.searchParams.get('limit') ?? undefined, offset: request.nextUrl.searchParams.get('offset') ?? undefined, }); + const parsedFilters = adminOrdersFilterInputSchema.safeParse({ + status: request.nextUrl.searchParams.get('status') ?? undefined, + dateFrom: request.nextUrl.searchParams.get('dateFrom') ?? undefined, + dateTo: request.nextUrl.searchParams.get('dateTo') ?? undefined, + }); + + if (!parsedQuery.success || !parsedFilters.success) { + const queryError = !parsedQuery.success ? parsedQuery.error.format() : {}; + const filtersError = !parsedFilters.success + ? parsedFilters.error.format() + : {}; - if (!parsedQuery.success) { logWarn('admin_orders_list_invalid_query', { ...baseMeta, code: 'INVALID_QUERY', - issuesCount: parsedQuery.error.issues?.length ?? 0, + issuesCount: + (!parsedQuery.success ? parsedQuery.error.issues.length : 0) + + (!parsedFilters.success ? parsedFilters.error.issues.length : 0), durationMs: Date.now() - startedAtMs, }); @@ -83,13 +99,20 @@ export async function GET(request: NextRequest) { { error: 'Invalid query', code: 'INVALID_QUERY', - details: parsedQuery.error.format(), + details: { + ...queryError, + ...filtersError, + }, }, { status: 400 } ); } - const { items, total } = await getAdminOrdersPage(parsedQuery.data); + const filters = normalizeAdminOrdersFilters(parsedFilters.data); + const { items, total } = await getAdminOrdersPage({ + ...parsedQuery.data, + ...filters, + }); return noStoreJson( { diff --git a/frontend/components/admin/shop/AdminPagination.tsx b/frontend/components/admin/shop/AdminPagination.tsx index 6fdd6b54..68194593 100644 --- a/frontend/components/admin/shop/AdminPagination.tsx +++ b/frontend/components/admin/shop/AdminPagination.tsx @@ -8,11 +8,29 @@ type AdminPaginationProps = { page: number; hasNext: boolean; className?: string; + query?: Record; }; -function pageHref(basePath: string, page: number) { - if (page <= 1) return basePath; - return `${basePath}?page=${page}`; +function pageHref( + basePath: string, + page: number, + query?: Record +) { + const params = new URLSearchParams(); + + if (query) { + for (const [key, value] of Object.entries(query)) { + if (!value) continue; + params.set(key, value); + } + } + + if (page > 1) { + params.set('page', String(page)); + } + + const queryString = params.toString(); + return queryString ? `${basePath}?${queryString}` : basePath; } export async function AdminPagination({ @@ -20,14 +38,15 @@ export async function AdminPagination({ page, hasNext, className, + query, }: AdminPaginationProps) { const hasPrev = page > 1; const t = await getTranslations('shop.admin.pagination'); const disabledClass = - 'inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-muted-foreground opacity-60'; + 'inline-flex h-9 items-center rounded-md border border-border px-3 text-sm font-medium text-muted-foreground opacity-60'; const linkClass = - 'inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary'; + 'inline-flex h-9 items-center rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-secondary'; return (