diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/CancelPaymentButton.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/CancelPaymentButton.tsx new file mode 100644 index 00000000..f8ad5d13 --- /dev/null +++ b/frontend/app/[locale]/admin/shop/orders/[id]/CancelPaymentButton.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useId, useState, useTransition } from 'react'; + +type Props = { + orderId: string; + disabled: boolean; + csrfToken: string; +}; + +function normalizeActionErrorCode(error: unknown): string { + if (error instanceof TypeError) { + return 'NETWORK_ERROR'; + } + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return 'NETWORK_ERROR'; +} + +function mapCancelPaymentError( + code: string, + t: (key: string) => string +): string { + switch (code) { + case 'NETWORK_ERROR': + return t('errors.network'); + case 'CSRF_REJECTED': + return t('errors.security'); + case 'CANCEL_DISABLED': + return t('errors.cancelPaymentDisabled'); + case 'CANCEL_PROVIDER_NOT_MONOBANK': + case 'CANCEL_NOT_ALLOWED': + return t('errors.cancelPaymentNotAvailable'); + case 'CANCEL_MISSING_PROVIDER_REF': + return t('errors.missingPaymentReference'); + case 'CANCEL_IN_PROGRESS': + return t('errors.cancelPaymentInProgress'); + case 'PSP_UNAVAILABLE': + return t('errors.providerUnavailable'); + case 'ADMIN_API_DISABLED': + return t('errors.adminDisabled'); + case 'INTERNAL_ERROR': + case 'HTTP_500': + return t('errors.generic'); + default: + return t('errors.generic'); + } +} + +export function CancelPaymentButton({ orderId, disabled, csrfToken }: Props) { + const router = useRouter(); + const t = useTranslations('shop.orders.detail.paymentControls'); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const errorId = useId(); + + async function onCancelPayment() { + setError(null); + + let res: Response; + try { + res = await fetch(`/api/shop/admin/orders/${orderId}/cancel-payment`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, + }); + } catch (err) { + setError(mapCancelPaymentError(normalizeActionErrorCode(err), t)); + return; + } + + let json: any = null; + try { + json = await res.json(); + } catch { + // ignore + } + + if (!res.ok) { + setError( + mapCancelPaymentError( + json?.error ?? json?.code ?? json?.message ?? `HTTP_${res.status}`, + t + ) + ); + return; + } + + startTransition(() => { + router.refresh(); + }); + } + + const isDisabled = disabled || isPending; + + return ( +
+ + + {error ? ( + + {error} + + ) : null} +
+ ); +} diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx index e934032c..bd75cb82 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx @@ -10,14 +10,57 @@ type Props = { csrfToken: string; }; +function normalizeActionErrorCode(error: unknown): string { + if (error instanceof TypeError) { + return 'NETWORK_ERROR'; + } + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return 'NETWORK_ERROR'; +} + +function mapRefundError(code: string, t: (key: string) => string): string { + switch (code) { + case 'NETWORK_ERROR': + return t('errors.network'); + case 'CSRF_REJECTED': + return t('errors.security'); + case 'REFUND_PROVIDER_NOT_STRIPE': + case 'REFUND_ORDER_NOT_PAID': + return t('errors.refundNotAvailable'); + case 'REFUND_MISSING_PSP_TARGET': + return t('errors.missingPaymentReference'); + case 'REFUND_ORDER_MONEY_INVALID': + return t('errors.invalidAmount'); + case 'PSP_UNAVAILABLE': + return t('errors.providerUnavailable'); + case 'ADMIN_API_DISABLED': + return t('errors.adminDisabled'); + case 'INTERNAL_ERROR': + case 'HTTP_500': + return t('errors.generic'); + default: + return t('errors.generic'); + } +} + export function RefundButton({ orderId, disabled, csrfToken }: Props) { const router = useRouter(); - const t = useTranslations('shop.admin.refund'); + const t = useTranslations('shop.orders.detail.paymentControls'); const [isPending, startTransition] = useTransition(); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const errorId = useId(); async function onRefund() { + if (disabled || isSubmitting || isPending) { + return; + } + + setIsSubmitting(true); setError(null); let res: Response; @@ -31,9 +74,8 @@ export function RefundButton({ orderId, disabled, csrfToken }: Props) { }, }); } catch (err) { - const msg = - err instanceof Error && err.message ? err.message : 'NETWORK_ERROR'; - setError(msg); + setError(mapRefundError(normalizeActionErrorCode(err), t)); + setIsSubmitting(false); return; } @@ -45,33 +87,44 @@ export function RefundButton({ orderId, disabled, csrfToken }: Props) { } if (!res.ok) { - setError(json?.error ?? json?.code ?? `HTTP_${res.status}`); + setError( + mapRefundError( + json?.error ?? json?.code ?? json?.message ?? `HTTP_${res.status}`, + t + ) + ); + setIsSubmitting(false); return; } + setIsSubmitting(false); startTransition(() => { router.refresh(); }); } - const isDisabled = disabled || isPending; + const isDisabled = disabled || isSubmitting || isPending; return ( -
+
{error ? ( - + {error} ) : null} diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx index feeedb7f..07707a00 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx @@ -1,46 +1,82 @@ 'use client'; import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { useId, useState, useTransition } from 'react'; -type ActionName = 'retry_label_creation' | 'mark_shipped' | 'mark_delivered'; +import { getAdminOrderShippingActionVisibility } from './shippingActionVisibility'; + +type ActionName = + | 'recover_initial_shipment' + | 'retry_label_creation' + | 'mark_shipped' + | 'mark_delivered'; type Props = { orderId: string; csrfToken: string; + shippingReady: boolean; shippingStatus: string | null; shipmentStatus: string | null; }; -function actionEnabled(args: { - action: ActionName; - shippingStatus: string | null; - shipmentStatus: string | null; -}): boolean { - if (args.action === 'retry_label_creation') { - return ( - args.shipmentStatus === 'failed' || - args.shipmentStatus === 'needs_attention' - ); +function normalizeActionErrorCode(error: unknown): string { + if (error instanceof TypeError) { + return 'NETWORK_ERROR'; } - if (args.action === 'mark_shipped') { - return args.shippingStatus === 'label_created'; + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return 'NETWORK_ERROR'; +} + +function mapShippingError(code: string, t: (key: string) => string): string { + switch (code) { + case 'NETWORK_ERROR': + return t('errors.network'); + case 'CSRF_REJECTED': + return t('errors.security'); + case 'SHIPMENT_ALREADY_EXISTS': + case 'SHIPMENT_RECOVERY_NOT_ALLOWED': + return t('errors.recoverNotAvailable'); + case 'SHIPMENT_NOT_FOUND': + return t('errors.shipmentMissing'); + case 'RETRY_NOT_ALLOWED': + return t('errors.retryNotAvailable'); + case 'INVALID_SHIPPING_TRANSITION': + return t('errors.transitionNotAvailable'); + case 'ADMIN_API_DISABLED': + return t('errors.adminDisabled'); + case 'INTERNAL_ERROR': + case 'HTTP_500': + return t('errors.generic'); + default: + return t('errors.generic'); } - return args.shippingStatus === 'shipped'; } export function ShippingActions({ orderId, csrfToken, + shippingReady, shippingStatus, shipmentStatus, }: Props) { const router = useRouter(); + const t = useTranslations('shop.orders.detail.shippingControls'); const [isPending, startTransition] = useTransition(); + const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const errorId = useId(); async function runAction(action: ActionName) { + if (isSubmitting || isPending) { + return; + } + + setIsSubmitting(true); setError(null); let res: Response; @@ -55,9 +91,8 @@ export function ShippingActions({ body: JSON.stringify({ action }), }); } catch (err) { - const msg = - err instanceof Error && err.message ? err.message : 'NETWORK_ERROR'; - setError(msg); + setError(mapShippingError(normalizeActionErrorCode(err), t)); + setIsSubmitting(false); return; } @@ -69,67 +104,87 @@ export function ShippingActions({ } if (!res.ok) { - setError(json?.code ?? json?.message ?? `HTTP_${res.status}`); + setError( + mapShippingError(json?.code ?? json?.message ?? `HTTP_${res.status}`, t) + ); + setIsSubmitting(false); return; } + setIsSubmitting(false); startTransition(() => { router.refresh(); }); } - const retryEnabled = actionEnabled({ - action: 'retry_label_creation', - shippingStatus, - shipmentStatus, - }); - const shippedEnabled = actionEnabled({ - action: 'mark_shipped', - shippingStatus, - shipmentStatus, - }); - const deliveredEnabled = actionEnabled({ - action: 'mark_delivered', + const visibility = getAdminOrderShippingActionVisibility({ + shippingReady, shippingStatus, shipmentStatus, }); + const visibleActions: Array<{ + action: ActionName; + label: string; + tone?: 'default' | 'emphasis'; + }> = []; + + if (visibility.recoverInitialShipment) { + visibleActions.push({ + action: 'recover_initial_shipment', + label: t('recoverInitialShipment'), + }); + } + + if (visibility.retryLabelCreation) { + visibleActions.push({ + action: 'retry_label_creation', + label: t('retryLabelCreation'), + }); + } + + if (visibility.markShipped) { + visibleActions.push({ + action: 'mark_shipped', + label: t('markShipped'), + tone: 'emphasis', + }); + } + + if (visibility.markDelivered) { + visibleActions.push({ + action: 'mark_delivered', + label: t('markDelivered'), + tone: 'emphasis', + }); + } return (
-
- - - - - +
+ {visibleActions.map(({ action, label, tone }) => ( + + ))}
{error ? ( - ) : null} diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/lifecycle/route.ts b/frontend/app/[locale]/admin/shop/orders/[id]/lifecycle/route.ts new file mode 100644 index 00000000..4f0248fe --- /dev/null +++ b/frontend/app/[locale]/admin/shop/orders/[id]/lifecycle/route.ts @@ -0,0 +1,234 @@ +import crypto from 'node:crypto'; + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminApi, +} from '@/lib/auth/admin'; +import { logError, logWarn } from '@/lib/logging'; +import { requireAdminCsrf } from '@/lib/security/admin-csrf'; +import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + AdminOrderLifecycleActionError, + applyAdminOrderLifecycleAction, +} from '@/lib/services/shop/admin-order-lifecycle'; +import { orderIdParamSchema } from '@/lib/validation/shop'; + +export const runtime = 'nodejs'; + +const payloadSchema = z + .object({ + action: z.enum(['confirm', 'cancel', 'complete']), + }) + .strict(); + +function buildDetailUrl(args: { + request: NextRequest; + locale: string; + orderId: string; + errorCode?: string | null; +}): URL { + const url = new URL( + `/${args.locale}/admin/shop/orders/${args.orderId}`, + args.request.url + ); + if (args.errorCode) { + url.searchParams.set('lifecycleError', args.errorCode); + } else { + url.searchParams.delete('lifecycleError'); + } + return url; +} + +function redirectToDetail(args: { + request: NextRequest; + locale: string; + orderId: string; + errorCode?: string | null; +}) { + return NextResponse.redirect(buildDetailUrl(args), { status: 303 }); +} + +function buildLoginUrl(args: { + request: NextRequest; + locale: string; + orderId: string; +}): URL { + const returnTo = `/${args.locale}/admin/shop/orders/${args.orderId}`; + const url = new URL(`/${args.locale}/login`, args.request.url); + url.searchParams.set('returnTo', returnTo); + return url; +} + +function redirectToLogin(args: { + request: NextRequest; + locale: string; + orderId: string; +}) { + return NextResponse.redirect(buildLoginUrl(args), { status: 303 }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ locale: string; id: string }> } +) { + const startedAtMs = Date.now(); + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: request.nextUrl.pathname, + method: request.method, + }; + + let localeForRedirect = 'en'; + let orderIdForLog: string | null = null; + let orderIdForRedirect = ''; + + const blocked = guardBrowserSameOrigin(request); + if (blocked) { + logWarn('admin_orders_lifecycle_origin_blocked', { + ...baseMeta, + code: 'ORIGIN_BLOCKED', + durationMs: Date.now() - startedAtMs, + }); + return blocked; + } + + try { + const rawParams = await context.params; + localeForRedirect = + typeof rawParams.locale === 'string' && rawParams.locale.trim().length > 0 + ? rawParams.locale + : 'en'; + orderIdForRedirect = + typeof rawParams.id === 'string' ? rawParams.id.trim() : ''; + + const parsed = orderIdParamSchema.safeParse({ id: rawParams.id }); + if (!parsed.success) { + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForRedirect, + errorCode: 'INVALID_ORDER_ID', + }); + } + orderIdForLog = parsed.data.id; + orderIdForRedirect = parsed.data.id; + + const adminUser = await requireAdminApi(request); + const formData = await request.formData(); + const csrfRes = requireAdminCsrf( + request, + 'admin:orders:lifecycle', + formData + ); + if (csrfRes) { + logWarn('admin_orders_lifecycle_csrf_rejected', { + ...baseMeta, + orderId: orderIdForLog, + code: 'CSRF_REJECTED', + durationMs: Date.now() - startedAtMs, + }); + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForLog, + errorCode: 'CSRF_REJECTED', + }); + } + + const parsedPayload = payloadSchema.safeParse({ + action: formData.get('action'), + }); + if (!parsedPayload.success) { + logWarn('admin_orders_lifecycle_invalid_payload', { + ...baseMeta, + orderId: orderIdForLog, + code: 'INVALID_PAYLOAD', + durationMs: Date.now() - startedAtMs, + }); + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForLog, + errorCode: 'INVALID_PAYLOAD', + }); + } + + await applyAdminOrderLifecycleAction({ + orderId: orderIdForLog, + action: parsedPayload.data.action, + actorUserId: + typeof adminUser.id === 'string' && adminUser.id.trim().length > 0 + ? adminUser.id + : null, + requestId, + }); + + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForLog, + }); + } catch (error) { + if (error instanceof AdminApiDisabledError) { + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForRedirect, + errorCode: error.code, + }); + } + + if (error instanceof AdminUnauthorizedError) { + return redirectToLogin({ + request, + locale: localeForRedirect, + orderId: orderIdForRedirect, + }); + } + + if (error instanceof AdminForbiddenError) { + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForRedirect, + errorCode: error.code, + }); + } + + if (error instanceof AdminOrderLifecycleActionError) { + logWarn('admin_orders_lifecycle_rejected', { + ...baseMeta, + orderId: orderIdForLog, + code: error.code, + durationMs: Date.now() - startedAtMs, + }); + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForRedirect, + errorCode: error.code, + }); + } + + logError('admin_orders_lifecycle_failed', error, { + ...baseMeta, + orderId: orderIdForLog, + code: 'ADMIN_ORDER_LIFECYCLE_FAILED', + durationMs: Date.now() - startedAtMs, + }); + + return redirectToDetail({ + request, + locale: localeForRedirect, + orderId: orderIdForRedirect, + errorCode: 'INTERNAL_ERROR', + }); + } +} diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx index 1ed6a0d1..c7ea2c2e 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx @@ -1,21 +1,21 @@ import 'server-only'; -import { eq } from 'drizzle-orm'; import { Metadata } from 'next'; import { notFound, redirect } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; -import { db } from '@/db'; -import { orderItems, orders } from '@/db/schema'; +import { + type AdminOrderDetail, + type AdminOrderHistoryEntry, + getAdminOrderDetail, + getAdminOrderTimeline, +} from '@/db/queries/shop/admin-orders'; import { Link } from '@/i18n/routing'; import { getCurrentUser } from '@/lib/auth'; import { logError } from '@/lib/logging'; -import { - type CanonicalFulfillmentStage, - deriveCanonicalFulfillmentStage, - latestReturnStatusSql, - latestShipmentStatusSql, -} from '@/lib/services/shop/fulfillment-stage'; +import { CSRF_FORM_FIELD, issueCsrfToken } from '@/lib/security/csrf'; +import { getAdminOrderLifecycleAvailability } from '@/lib/services/shop/admin-order-lifecycle'; +import { evaluateOrderShippingEligibility } from '@/lib/services/shop/shipping/eligibility'; import { type CurrencyCode, formatMoney } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; import { @@ -27,42 +27,31 @@ import { import { cn } from '@/lib/utils'; import { orderIdParamSchema } from '@/lib/validation/shop'; +import { CancelPaymentButton } from './CancelPaymentButton'; +import { RefundButton } from './RefundButton'; +import { ShippingActions } from './ShippingActions'; +import { getAdminOrderShippingActionVisibility } from './shippingActionVisibility'; + export const metadata: Metadata = { title: 'Order Details | DevLovers', description: 'Order details, items, totals, and current status.', }; export const dynamic = 'force-dynamic'; -type OrderCurrency = (typeof orders.$inferSelect)['currency']; -type OrderPaymentStatus = (typeof orders.$inferSelect)['paymentStatus']; -type OrderPaymentProvider = (typeof orders.$inferSelect)['paymentProvider']; - -type OrderDetail = { - id: string; - userId: string | null; - totalAmount: string; - currency: OrderCurrency; - paymentStatus: OrderPaymentStatus; - paymentProvider: OrderPaymentProvider; - paymentIntentId: string | null; - fulfillmentStage: CanonicalFulfillmentStage; - shippingStatus: string | null; - trackingNumber: string | null; - stockRestored: boolean; - restockedAt: string | null; - idempotencyKey: string; - createdAt: string; - updatedAt: string; - items: Array<{ - id: string; - productId: string; - productTitle: string | null; - productSlug: string | null; - productSku: string | null; - quantity: number; - unitPrice: string; - lineTotal: string; - }>; +const DASH = '-'; + +type NormalizedCustomerSummary = { + accountName: string | null; + accountEmail: string | null; + recipientName: string | null; + recipientPhone: string | null; + recipientEmail: string | null; + recipientComment: string | null; + shippingProvider: string | null; + shippingMethod: string | null; + city: string | null; + pickupPoint: string | null; + address: string | null; }; function safeFormatMoneyMajor( @@ -77,58 +66,329 @@ function safeFormatMoneyMajor( } } -function safeFormatDateTime(iso: string, dtf: Intl.DateTimeFormat): string { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return dtf.format(d); +function safeFormatDateTime( + value: Date | string | null, + dtf: Intl.DateTimeFormat +): string { + if (!value) return DASH; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return dtf.format(date); } -function fulfillmentStageLabelKey(stage: CanonicalFulfillmentStage): string { +function fulfillmentStageLabelKey( + stage: AdminOrderDetail['fulfillmentStage'] +): string { return `fulfillmentStages.${stage}`; } -function toOrderItem( - item: { - id: string | null; - productId: string | null; - productTitle: string | null; - productSlug: string | null; - productSku: string | null; - quantity: number | null; - unitPrice: string | null; - lineTotal: string | null; - } | null -): OrderDetail['items'][number] | null { - if (!item || !item.id) return null; - - if ( - !item.productId || - item.quantity === null || - !item.unitPrice || - !item.lineTotal - ) { - throw new Error('Corrupt order item row: required columns are null'); +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function toStringOrNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function firstNonEmpty( + ...values: Array +): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + + return null; +} + +function joinNonEmpty(values: Array): string | null { + const parts = values.filter( + (value): value is string => + typeof value === 'string' && value.trim().length > 0 + ); + + return parts.length > 0 ? parts.join(', ') : null; +} + +function normalizeCustomerSummary( + order: AdminOrderDetail +): NormalizedCustomerSummary { + const root = isRecord(order.shippingAddress) ? order.shippingAddress : {}; + const selection = isRecord(root.selection) ? root.selection : {}; + const recipient = isRecord(root.recipient) ? root.recipient : {}; + + const city = firstNonEmpty( + toStringOrNull(selection.cityNameUa), + toStringOrNull(selection.cityNameRu), + toStringOrNull(selection.cityRef) + ); + + const pickupPoint = firstNonEmpty( + toStringOrNull(selection.warehouseName), + toStringOrNull(selection.warehouseRef) + ); + + const address = joinNonEmpty([ + toStringOrNull(selection.addressLine1), + toStringOrNull(selection.addressLine2), + ]); + + return { + accountName: order.customerAccountName, + accountEmail: order.customerAccountEmail, + recipientName: toStringOrNull(recipient.fullName), + recipientPhone: toStringOrNull(recipient.phone), + recipientEmail: toStringOrNull(recipient.email), + recipientComment: toStringOrNull(recipient.comment), + shippingProvider: firstNonEmpty( + order.shippingProvider, + toStringOrNull(root.provider) + ), + shippingMethod: firstNonEmpty( + order.shippingMethodCode, + toStringOrNull(root.methodCode) + ), + city, + pickupPoint, + address, + }; +} + +function detailValue(value: string | null | undefined): string { + return value && value.trim().length > 0 ? value : DASH; +} + +function humanizeShippingProvider( + value: string | null, + t: (key: string) => string +): string { + if (value === 'nova_poshta') return t('shippingProviders.novaPoshta'); + return humanizeCode(value); +} + +function humanizeShippingMethod( + value: string | null, + t: (key: string) => string +): string { + switch (value) { + case 'NP_WAREHOUSE': + return t('shippingMethods.novaPoshtaWarehouse'); + case 'NP_LOCKER': + return t('shippingMethods.novaPoshtaLocker'); + case 'NP_COURIER': + return t('shippingMethods.novaPoshtaCourier'); + default: + return humanizeCode(value); + } +} + +function humanizeCode(value: string | null): string { + if (!value || value.trim().length === 0) return DASH; + + return value + .trim() + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, char => char.toUpperCase()); +} + +function humanizePaymentStatus( + value: string | null, + t: (key: string) => string +): string { + switch (value) { + case 'pending': + return t('pending'); + case 'requires_payment': + return t('requiresPayment'); + case 'paid': + return t('paid'); + case 'failed': + return t('failed'); + case 'refunded': + return t('refunded'); + case 'needs_review': + return t('needsReview'); + default: + return humanizeCode(value); + } +} + +function humanizePaymentProvider( + value: string | null, + t: (key: string) => string +): string { + switch (value) { + case 'stripe': + return t('paymentProviders.stripe'); + case 'monobank': + return t('paymentProviders.monobank'); + default: + return humanizeCode(value); + } +} + +function humanizeShippingStatus( + value: string | null, + t: (key: string) => string +): string { + switch (value) { + case 'pending': + return t('shippingStatuses.pending'); + case 'queued': + return t('shippingStatuses.queued'); + case 'creating_label': + return t('shippingStatuses.creatingLabel'); + case 'label_created': + return t('shippingStatuses.labelCreated'); + case 'shipped': + return t('shippingStatuses.shipped'); + case 'delivered': + return t('shippingStatuses.delivered'); + case 'cancelled': + return t('shippingStatuses.cancelled'); + case 'needs_attention': + return t('shippingStatuses.needsAttention'); + default: + return humanizeCode(value); + } +} + +function humanizeShipmentStatus( + value: string | null, + t: (key: string) => string +): string { + switch (value) { + case 'queued': + return t('shipmentStatuses.queued'); + case 'created': + return t('shipmentStatuses.created'); + case 'succeeded': + return t('shipmentStatuses.succeeded'); + case 'failed': + return t('shipmentStatuses.failed'); + case 'needs_attention': + return t('shipmentStatuses.needsAttention'); + default: + return humanizeCode(value); + } +} + +function historyActionLabel( + action: AdminOrderHistoryEntry['action'], + t: (key: string, values?: Record) => string +): string { + switch (action) { + case 'confirm': + return t('history.actions.confirm'); + case 'cancel': + return t('history.actions.cancel'); + case 'complete': + return t('history.actions.complete'); + case 'recover_initial_shipment': + return t('history.actions.recoverInitialShipment'); + case 'retry_label_creation': + return t('history.actions.retryLabelCreation'); + case 'mark_shipped': + return t('history.actions.markShipped'); + case 'mark_delivered': + return t('history.actions.markDelivered'); + default: + return action; + } +} + +function renderHistoryActor( + entry: AdminOrderHistoryEntry, + t: (key: string, values?: Record) => string +) { + if (entry.actorEmail) { + return ( +
+
+ {entry.actorName ?? entry.actorEmail} +
+ {entry.actorName ? ( +
+ {entry.actorEmail} +
+ ) : null} +
+ ); } + if (entry.actorName) { + return {entry.actorName}; + } + + if (entry.actorUserId) { + return {t('history.adminUser')}; + } + + return {t('history.system')}; +} + +function lifecycleErrorMessageKey(code: string | null): string | null { + if (!code) return null; + + switch (code) { + case 'ORDER_CONFIRM_REQUIRES_PAID_PAYMENT': + return 'lifecycle.errors.confirmRequiresPaid'; + case 'ORDER_CONFIRM_NOT_ALLOWED': + case 'ORDER_CONFIRM_INVENTORY_NOT_READY': + return 'lifecycle.errors.confirmNotAllowed'; + case 'ORDER_CANCEL_REQUIRES_REFUND': + return 'lifecycle.errors.cancelRequiresRefund'; + case 'ORDER_COMPLETE_REQUIRES_SHIPPING': + return 'lifecycle.errors.completeRequiresShipping'; + case 'ORDER_COMPLETE_NOT_ALLOWED': + case 'ORDER_COMPLETE_SHIPMENT_STATE_INCOMPATIBLE': + return 'lifecycle.errors.completeNotAllowed'; + case 'CSRF_REJECTED': + return 'lifecycle.errors.csrfRejected'; + case 'INVALID_PAYLOAD': + return 'lifecycle.errors.invalidPayload'; + case 'INTERNAL_ERROR': + return 'lifecycle.errors.internalError'; + default: + return 'lifecycle.errors.generic'; + } +} + +function paymentControlsEnabled(order: AdminOrderDetail): { + refund: boolean; + cancelPayment: boolean; +} { return { - id: item.id, - productId: item.productId, - productTitle: item.productTitle, - productSlug: item.productSlug, - productSku: item.productSku, - quantity: item.quantity, - unitPrice: item.unitPrice, - lineTotal: item.lineTotal, + refund: + order.paymentProvider === 'stripe' && order.paymentStatus === 'paid', + cancelPayment: + order.paymentProvider === 'monobank' && + (order.paymentStatus === 'pending' || + order.paymentStatus === 'requires_payment') && + order.status !== 'PAID' && + order.status !== 'CANCELED', }; } export default async function OrderDetailPage({ params, + searchParams, }: { params: Promise<{ locale: string; id: string }>; + searchParams?: Promise<{ lifecycleError?: string | string[] }>; }) { const { locale, id } = await params; + const sp = searchParams ? await searchParams : {}; const t = await getTranslations('shop.orders.detail'); + const paymentStatusT = await getTranslations('shop.orders.paymentStatus'); + const lifecycleCsrfToken = issueCsrfToken('admin:orders:lifecycle'); + const shippingCsrfToken = issueCsrfToken('admin:orders:shipping:action'); + const refundCsrfToken = issueCsrfToken('admin:orders:refund'); + const cancelPaymentCsrfToken = issueCsrfToken('admin:orders:cancel-payment'); const user = await getCurrentUser(); if (!user) { @@ -142,97 +402,24 @@ export default async function OrderDetailPage({ const parsed = orderIdParamSchema.safeParse({ id }); if (!parsed.success) notFound(); - const isAdmin = user.role === 'admin'; - if (!isAdmin) notFound(); - - let order: OrderDetail; - - const whereClause = eq(orders.id, parsed.data.id); - - const rows = await (async () => { - try { - return await db - .select({ - order: { - id: orders.id, - userId: orders.userId, - totalAmount: orders.totalAmount, - currency: orders.currency, - paymentStatus: orders.paymentStatus, - paymentProvider: orders.paymentProvider, - paymentIntentId: orders.paymentIntentId, - orderStatus: orders.status, - shippingStatus: orders.shippingStatus, - shipmentStatus: latestShipmentStatusSql(orders.id), - returnStatus: latestReturnStatusSql(orders.id), - trackingNumber: orders.trackingNumber, - stockRestored: orders.stockRestored, - restockedAt: orders.restockedAt, - idempotencyKey: orders.idempotencyKey, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - }, - item: { - id: orderItems.id, - productId: orderItems.productId, - productTitle: orderItems.productTitle, - productSlug: orderItems.productSlug, - productSku: orderItems.productSku, - quantity: orderItems.quantity, - unitPrice: orderItems.unitPrice, - lineTotal: orderItems.lineTotal, - }, - }) - .from(orders) - .leftJoin(orderItems, eq(orderItems.orderId, orders.id)) - .where(whereClause) - .orderBy(orderItems.id); - } catch (error) { - logError('Admin order detail page failed', error); - throw new Error('ORDER_DETAIL_LOAD_FAILED'); - } - })(); + if (user.role !== 'admin') notFound(); - if (rows.length === 0) notFound(); + let order: AdminOrderDetail | null; + let history: AdminOrderHistoryEntry[]; try { - const base = rows[0]!.order; - const fulfillmentStage = deriveCanonicalFulfillmentStage({ - orderStatus: base.orderStatus, - shippingStatus: base.shippingStatus, - shipmentStatus: - typeof base.shipmentStatus === 'string' ? base.shipmentStatus : null, - returnStatus: - typeof base.returnStatus === 'string' ? base.returnStatus : null, - }); - - const items = rows - .map(r => toOrderItem(r.item)) - .filter((i): i is NonNullable => i !== null); - - order = { - id: base.id, - userId: base.userId, - totalAmount: base.totalAmount, - currency: base.currency, - paymentStatus: base.paymentStatus, - paymentProvider: base.paymentProvider, - paymentIntentId: base.paymentIntentId, - fulfillmentStage, - shippingStatus: base.shippingStatus, - trackingNumber: base.trackingNumber, - stockRestored: base.stockRestored, - idempotencyKey: base.idempotencyKey, - createdAt: base.createdAt.toISOString(), - updatedAt: base.updatedAt.toISOString(), - restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null, - items, - }; + [order, history] = await Promise.all([ + getAdminOrderDetail(parsed.data.id), + getAdminOrderTimeline(parsed.data.id), + ]); } catch (error) { logError('Admin order detail page failed', error); throw new Error('ORDER_DETAIL_LOAD_FAILED'); } + if (!order) notFound(); + + const customerSummary = normalizeCustomerSummary(order); const currency: CurrencyCode = order.currency === 'UAH' ? 'UAH' : 'USD'; const dtf = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', @@ -245,9 +432,66 @@ export default async function OrderDetailPage({ locale ); const createdFormatted = safeFormatDateTime(order.createdAt, dtf); - const restockedFormatted = order.restockedAt - ? safeFormatDateTime(order.restockedAt, dtf) - : '—'; + const restockedFormatted = safeFormatDateTime(order.restockedAt, dtf); + const shippingProviderLabel = humanizeShippingProvider( + customerSummary.shippingProvider, + t + ); + const shippingMethodLabel = humanizeShippingMethod( + customerSummary.shippingMethod, + t + ); + const enabled = getAdminOrderLifecycleAvailability({ + status: order.status, + paymentStatus: order.paymentStatus, + inventoryStatus: order.inventoryStatus, + shippingRequired: order.shippingRequired, + shippingProvider: order.shippingProvider, + shippingMethodCode: order.shippingMethodCode, + shippingStatus: order.shippingStatus, + pspStatusReason: order.pspStatusReason, + stockRestored: order.stockRestored, + shipmentStatus: order.shipmentStatus, + }); + const shippingReady = + order.shippingRequired === true && + order.shippingProvider === 'nova_poshta' && + !!order.shippingMethodCode && + evaluateOrderShippingEligibility({ + paymentStatus: order.paymentStatus, + orderStatus: order.status, + inventoryStatus: order.inventoryStatus, + pspStatusReason: order.pspStatusReason, + }).ok; + const shippingEnabled = getAdminOrderShippingActionVisibility({ + shippingReady, + shippingStatus: order.shippingStatus, + shipmentStatus: order.shipmentStatus, + }); + const paymentEnabled = paymentControlsEnabled(order); + const lifecycleErrorCode = Array.isArray(sp.lifecycleError) + ? (sp.lifecycleError[0] ?? null) + : (sp.lifecycleError ?? null); + const lifecycleErrorKey = lifecycleErrorMessageKey(lifecycleErrorCode); + const visibleLifecycle = { + confirm: enabled.confirm, + cancel: enabled.cancel, + complete: enabled.complete && !shippingEnabled.markDelivered, + }; + const showLifecycleActions = + visibleLifecycle.confirm || + visibleLifecycle.cancel || + visibleLifecycle.complete || + lifecycleErrorKey !== null; + const showShippingActions = + shippingEnabled.recoverInitialShipment || + shippingEnabled.retryLabelCreation || + shippingEnabled.markShipped || + shippingEnabled.markDelivered; + const showPaymentActions = + paymentEnabled.refund || paymentEnabled.cancelPayment; + const showOperationalActions = + showLifecycleActions || showShippingActions || showPaymentActions; const NAV_LINK = cn(SHOP_NAV_LINK_BASE, 'text-lg', SHOP_FOCUS); const PRODUCT_LINK = cn( @@ -260,12 +504,15 @@ export default async function OrderDetailPage({ return (
-

+

{t('title')}

@@ -287,107 +534,504 @@ export default async function OrderDetailPage({
-

- {t('orderSummary')} -

- -
-
-
{t('total')}
-
{totalFormatted}
-
+
+

+ {t('actions.heading')} +

+

+ {t('actions.subtitle')} +

+
-
-
- {t('paymentStatus')} -
-
- {String(order.paymentStatus)} -
-
+ {showOperationalActions ? ( +
+ {showLifecycleActions ? ( +
+
+
+ {t('lifecycle.eyebrow')} +
+

+ {t('lifecycle.heading')} +

+
+

+ {t('lifecycle.subtitle')} +

-
-
- {t('fulfillmentStage')} -
-
- {t(fulfillmentStageLabelKey(order.fulfillmentStage))} -
-
+
+ {visibleLifecycle.confirm ? ( +
+ + + +
+ ) : null} -
-
- {t('shippingStatus')} -
-
- {order.shippingStatus ?? '-'} -
-
+ {visibleLifecycle.cancel ? ( +
+ + + +
+ ) : null} -
-
- {t('trackingNumber')} -
-
- {order.trackingNumber ?? '-'} -
-
+ {visibleLifecycle.complete ? ( +
+ + + +
+ ) : null} +
-
-
{t('created')}
-
{createdFormatted}
-
+ {lifecycleErrorKey ? ( +

+ {t(lifecycleErrorKey)} +

+ ) : null} +
+ ) : null} -
-
{t('provider')}
-
{String(order.paymentProvider)}
-
-
- -
-
-
- {t('paymentReference')} -
-
- {order.paymentIntentId ?? '—'} -
-
-
-
- {t('idempotencyKey')} -
-
{order.idempotencyKey}
+ {showShippingActions ? ( +
+
+
+ {t('shippingControls.eyebrow')} +
+

+ {t('shippingControls.heading')} +

+
+

+ {t('shippingControls.subtitle')} +

+
+ +
+
+ ) : null} + + {showPaymentActions ? ( +
+
+
+ {t('paymentControls.eyebrow')} +
+

+ {t('paymentControls.heading')} +

+
+

+ {t('paymentControls.subtitle')} +

+ +
+ {paymentEnabled.refund ? ( + + ) : null} + + {paymentEnabled.cancelPayment ? ( + + ) : null} +
+
+ ) : null}
-
- -
-
-
- {t('stockRestored')} -
-
- {order.stockRestored ? t('yes') : t('no')} -
+ ) : ( +
+ {t('actions.empty')}
-
-
- {t('restockedAt')} -
-
{restockedFormatted}
+ )} +
+ +
+
+

+ {t('orderSummary')} +

+ +
+
+
{t('total')}
+
{totalFormatted}
+
+ +
+
+ {t('paymentStatus')} +
+
+ {humanizePaymentStatus(order.paymentStatus, paymentStatusT)} +
+
+ +
+
+ {t('fulfillmentStage')} +
+
+ {t(fulfillmentStageLabelKey(order.fulfillmentStage))} +
+
+ +
+
+ {t('shippingStatus')} +
+
+ {humanizeShippingStatus(order.shippingStatus, t)} +
+
+ +
+
+ {t('trackingNumber')} +
+
+ {detailValue(order.trackingNumber)} +
+
+ +
+
{t('created')}
+
{createdFormatted}
+
+ +
+
{t('provider')}
+
+ {humanizePaymentProvider(order.paymentProvider, t)} +
+
+ +
+
+ {t('paymentReference')} +
+
+ {detailValue(order.paymentIntentId)} +
+
+
+ +
+
+
+ {t('idempotencyKey')} +
+
{order.idempotencyKey}
+
+
+
+ {t('stockRestored')} +
+
+ {order.stockRestored ? t('yes') : t('no')} +
+
+
+
+ {t('restockedAt')} +
+
{restockedFormatted}
+
+
+
+ +
+

+ {t('customerSummary')} +

+ +
+
+
+ {t('customerAccount')} +
+
+ {order.userId ? ( + customerSummary.accountName || + customerSummary.accountEmail ? ( +
+
+ {customerSummary.accountName ?? + customerSummary.accountEmail} +
+ {customerSummary.accountName && + customerSummary.accountEmail ? ( +
+ {customerSummary.accountEmail} +
+ ) : null} +
+ ) : ( + t('accountUnavailable') + ) + ) : ( + t('guest') + )} +
+
+ +
+
+ {t('recipientName')} +
+
+ {detailValue(customerSummary.recipientName)} +
+
+ +
+
+ {t('recipientPhone')} +
+
+ {detailValue(customerSummary.recipientPhone)} +
+
+ +
+
+ {t('recipientEmail')} +
+
+ {detailValue(customerSummary.recipientEmail)} +
+
+ +
+
+ {t('shippingProviderLabel')} +
+
{shippingProviderLabel}
+
+ +
+
+ {t('shippingMethod')} +
+
{shippingMethodLabel}
+
+ +
+
{t('city')}
+
{detailValue(customerSummary.city)}
+
+ +
+
+ {t('pickupPoint')} +
+
+ {detailValue(customerSummary.pickupPoint)} +
+
+ +
+
{t('address')}
+
+ {detailValue(customerSummary.address)} +
+
+ +
+
{t('comment')}
+
+ {detailValue(customerSummary.recipientComment)} +
+
+
+
+
+ +
+
+

+ {t('history.heading')} +

+

+ {t('history.subtitle')} +

+
+ + {history.length === 0 ? ( +
+
+ {t('history.empty')} +
- + ) : ( +
    + {history.map(entry => { + const occurredAtLabel = safeFormatDateTime(entry.occurredAt, dtf); + const hasTransition = + entry.fromShippingStatus || entry.toShippingStatus; + + return ( +
  1. +
    + +
  2. + ); + })} +
+ )}
-

+

{t('items')}

@@ -423,19 +1067,21 @@ export default async function OrderDetailPage({
{t('quantity')}
-
Qty: {it.quantity}
+
+ {t('qtyShort')}: {it.quantity} +
{t('unitPrice')}
- Unit:{' '} + {t('unitShort')}:{' '} {safeFormatMoneyMajor(it.unitPrice, currency, locale)}
{t('lineTotal')}
- Line:{' '} + {t('lineShort')}:{' '} {safeFormatMoneyMajor(it.lineTotal, currency, locale)}
diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/shippingActionVisibility.ts b/frontend/app/[locale]/admin/shop/orders/[id]/shippingActionVisibility.ts new file mode 100644 index 00000000..b1a5580c --- /dev/null +++ b/frontend/app/[locale]/admin/shop/orders/[id]/shippingActionVisibility.ts @@ -0,0 +1,39 @@ +export type AdminOrderShippingActionVisibility = { + recoverInitialShipment: boolean; + retryLabelCreation: boolean; + markShipped: boolean; + markDelivered: boolean; +}; + +export function getAdminOrderShippingActionVisibility(args: { + shippingReady: boolean; + shippingStatus: string | null; + shipmentStatus: string | null; +}): AdminOrderShippingActionVisibility { + if (!args.shippingReady) { + return { + recoverInitialShipment: false, + retryLabelCreation: false, + markShipped: false, + markDelivered: false, + }; + } + + const queueableShippingStatus = + args.shippingStatus == null || + args.shippingStatus === 'pending' || + args.shippingStatus === 'queued' || + args.shippingStatus === 'creating_label' || + args.shippingStatus === 'needs_attention'; + + return { + recoverInitialShipment: + queueableShippingStatus && + (args.shipmentStatus == null || args.shipmentStatus === 'queued'), + retryLabelCreation: + args.shipmentStatus === 'failed' || + args.shipmentStatus === 'needs_attention', + markShipped: args.shippingStatus === 'label_created', + markDelivered: args.shippingStatus === 'shipped', + }; +} diff --git a/frontend/app/[locale]/admin/shop/orders/page.tsx b/frontend/app/[locale]/admin/shop/orders/page.tsx index bef658a2..d4209f78 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,17 @@ export default async function AdminOrdersPage({ -
    +
    diff --git a/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx b/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx index 8cda28e2..82cc58e1 100644 --- a/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx @@ -1,7 +1,9 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; +import { Link } from '@/i18n/routing'; import { ProductNotFoundError } from '@/lib/errors/products'; import { issueCsrfToken } from '@/lib/security/csrf'; import { getAdminProductByIdWithPrices } from '@/lib/services/products'; @@ -48,6 +50,7 @@ export default async function EditProductPage({ } const prices = product.prices; + const t = await getTranslations('shop.admin.products'); const initialPrices = prices.length ? prices @@ -73,7 +76,20 @@ export default async function EditProductPage({ const csrfToken = issueCsrfToken('admin:products:update'); return ( -
    +
    +
    + + ← {t('backToList')} + +
    + +

    + {t('editProductHeading', { title: product.title })} +

    + -
    -

    - {mode === 'create' ? 'Create new product' : 'Edit product'} -

    -
    - +
    +

    + {mode === 'create' ? 'Create new product' : 'Edit product'} +

    {error ? ( ) : null}
    -
    +
    -
    -
    +
    Prices @@ -840,16 +845,13 @@ export function ProductForm({
    -