From 2c77e2a0f074a084798d1b24755758c2b5c340a1 Mon Sep 17 00:00:00 2001 From: Liudmyla Sovetovs Date: Tue, 24 Feb 2026 22:41:02 -0800 Subject: [PATCH 01/14] (SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow (#364) --- frontend/.env.example | 68 +- .../admin/shop/orders/[id]/RefundButton.tsx | 8 +- .../shop/orders/[id]/ShippingActions.tsx | 137 + .../[locale]/admin/shop/orders/[id]/page.tsx | 610 ++- .../app/[locale]/shop/cart/CartPageClient.tsx | 751 ++- .../app/[locale]/shop/orders/[id]/page.tsx | 51 +- .../app/api/shop/admin/orders/[id]/route.ts | 68 + .../shop/admin/orders/[id]/shipping/route.ts | 189 + frontend/app/api/shop/checkout/route.ts | 33 +- .../shop/internal/shipping/np/sync/route.ts | 234 + .../internal/shipping/retention/run/route.ts | 247 + .../internal/shipping/shipments/run/route.ts | 244 + frontend/app/api/shop/orders/[id]/route.ts | 4 + .../app/api/shop/shipping/methods/route.ts | 175 + .../app/api/shop/shipping/np/cities/route.ts | 171 + .../api/shop/shipping/np/warehouses/route.ts | 172 + .../app/api/shop/webhooks/stripe/route.ts | 174 +- frontend/db/queries/shop/admin-orders.ts | 38 +- frontend/db/schema/shop.ts | 206 + frontend/drizzle/0017_shop_shipping_core.sql | 83 + .../0018_shop_orders_shipping_invariants.sql | 33 + frontend/drizzle/0019_p2_shop_invariants.sql | 40 + frontend/drizzle/meta/0017_snapshot.json | 4314 ++++++++++++++++ frontend/drizzle/meta/0018_snapshot.json | 4314 ++++++++++++++++ frontend/drizzle/meta/0019_snapshot.json | 4360 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 23 +- frontend/lib/env/index.ts | 27 + frontend/lib/env/nova-poshta.ts | 130 + frontend/lib/services/orders/_shared.ts | 149 +- frontend/lib/services/orders/checkout.ts | 412 +- .../lib/services/orders/monobank-janitor.ts | 153 +- .../lib/services/orders/monobank-webhook.ts | 157 +- .../lib/services/orders/payment-attempts.ts | 28 + .../services/shop/shipping/admin-actions.ts | 436 ++ .../services/shop/shipping/availability.ts | 87 + .../shop/shipping/checkout-payload.ts | 169 + .../shop/shipping/inventory-eligibility.ts | 32 + .../services/shop/shipping/log-sanitizer.ts | 87 + .../lib/services/shop/shipping/metrics.ts | 50 + .../shop/shipping/nova-poshta-catalog.ts | 426 ++ .../shop/shipping/nova-poshta-client.ts | 495 ++ .../lib/services/shop/shipping/retention.ts | 67 + .../shop/shipping/shipments-worker.ts | 680 +++ frontend/lib/shop/locale.ts | 13 + .../tests/shop/admin-api-killswitch.test.ts | 7 + .../tests/shop/admin-csrf-contract.test.ts | 30 + .../shop/checkout-currency-policy.test.ts | 4 +- .../shop/checkout-shipping-phase3.test.ts | 335 ++ .../tests/shop/monobank-janitor-job3.test.ts | 3 +- .../monobank-webhook-apply-outcomes.test.ts | 16 +- .../tests/shop/monobank-webhook-apply.test.ts | 90 +- ...nova-poshta-client-network-failure.test.ts | 72 + .../shipping-checkout-payload-phase6.test.ts | 98 + ...shipping-internal-np-sync-route-p2.test.ts | 52 + ...ng-internal-retention-route-phase7.test.ts | 55 + .../shipping-log-sanitizer-phase7.test.ts | 40 + .../shop/shipping-methods-route-p2.test.ts | 80 + .../shop/shipping-np-cities-route-p2.test.ts | 158 + .../shipping-np-warehouses-route-p2.test.ts | 156 + .../shop/shipping-retention-phase7.test.ts | 134 + .../shipping-shipments-worker-phase5.test.ts | 329 ++ .../shop/stripe-webhook-psp-fields.test.ts | 34 + frontend/lib/types/shop.ts | 2 + frontend/lib/validation/shop-shipping.ts | 116 + frontend/lib/validation/shop.ts | 85 + frontend/messages/en.json | 67 +- frontend/messages/pl.json | 67 +- frontend/messages/uk.json | 67 +- frontend/package-lock.json | 6 +- frontend/package.json | 6 +- frontend/scripts/np-mock-server.mjs | 161 + frontend/scripts/seed-np-local.ps1 | 34 + frontend/scripts/seed-np-local.sql | 109 + 73 files changed, 22273 insertions(+), 485 deletions(-) create mode 100644 frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx create mode 100644 frontend/app/api/shop/admin/orders/[id]/shipping/route.ts create mode 100644 frontend/app/api/shop/internal/shipping/np/sync/route.ts create mode 100644 frontend/app/api/shop/internal/shipping/retention/run/route.ts create mode 100644 frontend/app/api/shop/internal/shipping/shipments/run/route.ts create mode 100644 frontend/app/api/shop/shipping/methods/route.ts create mode 100644 frontend/app/api/shop/shipping/np/cities/route.ts create mode 100644 frontend/app/api/shop/shipping/np/warehouses/route.ts create mode 100644 frontend/drizzle/0017_shop_shipping_core.sql create mode 100644 frontend/drizzle/0018_shop_orders_shipping_invariants.sql create mode 100644 frontend/drizzle/0019_p2_shop_invariants.sql create mode 100644 frontend/drizzle/meta/0017_snapshot.json create mode 100644 frontend/drizzle/meta/0018_snapshot.json create mode 100644 frontend/drizzle/meta/0019_snapshot.json create mode 100644 frontend/lib/env/nova-poshta.ts create mode 100644 frontend/lib/services/shop/shipping/admin-actions.ts create mode 100644 frontend/lib/services/shop/shipping/availability.ts create mode 100644 frontend/lib/services/shop/shipping/checkout-payload.ts create mode 100644 frontend/lib/services/shop/shipping/inventory-eligibility.ts create mode 100644 frontend/lib/services/shop/shipping/log-sanitizer.ts create mode 100644 frontend/lib/services/shop/shipping/metrics.ts create mode 100644 frontend/lib/services/shop/shipping/nova-poshta-catalog.ts create mode 100644 frontend/lib/services/shop/shipping/nova-poshta-client.ts create mode 100644 frontend/lib/services/shop/shipping/retention.ts create mode 100644 frontend/lib/services/shop/shipping/shipments-worker.ts create mode 100644 frontend/lib/shop/locale.ts create mode 100644 frontend/lib/tests/shop/checkout-shipping-phase3.test.ts create mode 100644 frontend/lib/tests/shop/nova-poshta-client-network-failure.test.ts create mode 100644 frontend/lib/tests/shop/shipping-checkout-payload-phase6.test.ts create mode 100644 frontend/lib/tests/shop/shipping-internal-np-sync-route-p2.test.ts create mode 100644 frontend/lib/tests/shop/shipping-internal-retention-route-phase7.test.ts create mode 100644 frontend/lib/tests/shop/shipping-log-sanitizer-phase7.test.ts create mode 100644 frontend/lib/tests/shop/shipping-methods-route-p2.test.ts create mode 100644 frontend/lib/tests/shop/shipping-np-cities-route-p2.test.ts create mode 100644 frontend/lib/tests/shop/shipping-np-warehouses-route-p2.test.ts create mode 100644 frontend/lib/tests/shop/shipping-retention-phase7.test.ts create mode 100644 frontend/lib/tests/shop/shipping-shipments-worker-phase5.test.ts create mode 100644 frontend/lib/validation/shop-shipping.ts create mode 100644 frontend/scripts/np-mock-server.mjs create mode 100644 frontend/scripts/seed-np-local.ps1 create mode 100644 frontend/scripts/seed-np-local.sql diff --git a/frontend/.env.example b/frontend/.env.example index fde0d20b..abb96456 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,10 +7,11 @@ NEXT_PUBLIC_SITE_URL= # --- Database DATABASE_URL= +DATABASE_URL_LOCAL= # --- Upstash Redis (REST) -UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +UPSTASH_REDIS_REST_URL= # --- Auth (app) AUTH_SECRET= @@ -46,25 +47,73 @@ CLOUDINARY_URL= # --- Payments (Stripe) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= -PAYMENTS_ENABLED= # Options: test, live (defaults to test in development, live in production) STRIPE_MODE= +STRIPE_PAYMENTS_ENABLED= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +# --- Payments (Monobank) +# Optional; set explicitly in production for clarity +MONO_API_BASE= +MONO_INVOICE_TIMEOUT_MS= + +# Required for Monobank checkout/webhooks +MONO_MERCHANT_TOKEN= +MONO_PUBLIC_KEY= + +# Optional webhook/runtime tuning (defaults in code if omitted) +MONO_REFUND_ENABLED=0 +MONO_WEBHOOK_CLAIM_TTL_MS= +MONO_WEBHOOK_MODE= + +PAYMENTS_ENABLED= + +# --- Shipping (Nova Poshta) +# Toggles (optional; defaults are handled in code) +SHOP_SHIPPING_ENABLED=0 +SHOP_SHIPPING_NP_ENABLED=0 + +# Retention (optional; days, used for cleanup/retention policies) +SHOP_SHIPPING_RETENTION_DAYS= + +# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1). +# If shipping is enabled without required NP config, app throws NovaPoshtaConfigError at runtime. +# Optional if code has a default; set explicitly in production for clarity +NP_API_BASE= +NP_API_KEY= +NP_SENDER_WAREHOUSE_REF= +NP_SENDER_CITY_REF= +NP_SENDER_CONTACT_REF= +NP_SENDER_NAME= +NP_SENDER_PHONE= +NP_SENDER_REF= + +# Optional tuning (override only if needed; otherwise code defaults apply) +NP_MAX_RETRIES= +NP_RETRY_DELAY_MS= +NP_TIMEOUT_MS= + # --- Admin / Internal ops ENABLE_ADMIN_API= INTERNAL_JANITOR_MIN_INTERVAL_SECONDS= INTERNAL_JANITOR_SECRET= JANITOR_URL= +# Optional internal/admin runtime secrets & tuning (used by internal endpoints/jobs) +INTERNAL_SECRET= +JANITOR_TIMEOUT_MS= + +# Optional instance IDs for webhook multi-instance diagnostics/claiming +STRIPE_WEBHOOK_INSTANCE_ID= +WEBHOOK_INSTANCE_ID= + # --- Quiz QUIZ_ENCRYPTION_KEY= # --- Web3Forms (feedback form) -NEXT_PUBLIC_WEB3FORMS_KEY= - GITHUB_SPONSORS_TOKEN= +NEXT_PUBLIC_WEB3FORMS_KEY= # --- Telegram TELEGRAM_BOT_TOKEN= @@ -75,6 +124,13 @@ EMAIL_FROM= GMAIL_APP_PASSWORD= GMAIL_USER= +# --- Shop / Internal +# Optional public/base URL used by shop services/links +SHOP_BASE_URL= + +# Required for signed shop status tokens (if status endpoint/token flow is enabled) +SHOP_STATUS_TOKEN_SECRET= + # --- Security CSRF_SECRET= @@ -107,6 +163,4 @@ TRUST_FORWARDED_HEADERS=0 # emergency switch RATE_LIMIT_DISABLED=0 -GROQ_API_KEY= - -NEXT_PUBLIC_WEB3FORMS_KEY= \ No newline at end of file +GROQ_API_KEY= \ No newline at end of file diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx index 52cfe22a..e934032c 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx @@ -7,9 +7,10 @@ import { useId, useState, useTransition } from 'react'; type Props = { orderId: string; disabled: boolean; + csrfToken: string; }; -export function RefundButton({ orderId, disabled }: Props) { +export function RefundButton({ orderId, disabled, csrfToken }: Props) { const router = useRouter(); const t = useTranslations('shop.admin.refund'); const [isPending, startTransition] = useTransition(); @@ -24,7 +25,10 @@ export function RefundButton({ orderId, disabled }: Props) { res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, { method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, }); } catch (err) { const msg = diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx new file mode 100644 index 00000000..9b66b6c5 --- /dev/null +++ b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useId, useState, useTransition } from 'react'; + +type ActionName = 'retry_label_creation' | 'mark_shipped' | 'mark_delivered'; + +type Props = { + orderId: string; + csrfToken: string; + 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' + ); + } + if (args.action === 'mark_shipped') { + return args.shippingStatus === 'label_created'; + } + return args.shippingStatus === 'shipped'; +} + +export function ShippingActions({ + orderId, + csrfToken, + shippingStatus, + shipmentStatus, +}: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const errorId = useId(); + + async function runAction(action: ActionName) { + setError(null); + + let res: Response; + try { + res = await fetch(`/api/shop/admin/orders/${orderId}/shipping`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ action }), + }); + } catch (err) { + const msg = + err instanceof Error && err.message ? err.message : 'NETWORK_ERROR'; + setError(msg); + return; + } + + let json: any = null; + try { + json = await res.json(); + } catch { + // ignore + } + + if (!res.ok) { + setError(json?.code ?? json?.message ?? `HTTP_${res.status}`); + return; + } + + 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', + shippingStatus, + shipmentStatus, + }); + + return ( +
+
+ + + + + +
+ + {error ? ( + + ) : null} +
+ ); +} diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx index 365babb2..a38f575f 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx @@ -1,279 +1,407 @@ +import 'server-only'; + +import { eq } from 'drizzle-orm'; import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; -import { getAdminOrderDetail } from '@/db/queries/shop/admin-orders'; +import { db } from '@/db'; +import { orderItems, orders } from '@/db/schema'; import { Link } from '@/i18n/routing'; -import { - type CurrencyCode, - formatMoney, - resolveCurrencyFromLocale, -} from '@/lib/shop/currency'; +import { getCurrentUser } from '@/lib/auth'; +import { logError } from '@/lib/logging'; +import { type CurrencyCode, formatMoney } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; - -import { RefundButton } from './RefundButton'; +import { + SHOP_FOCUS, + SHOP_LINK_BASE, + SHOP_LINK_MD, + SHOP_NAV_LINK_BASE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; +import { orderIdParamSchema } from '@/lib/validation/shop'; export const metadata: Metadata = { - title: 'Admin Order | DevLovers', - description: 'Review and manage order, including refunds and status checks.', + 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; + 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; + }>; }; -function pickMinor(minor: unknown, legacyMajor: unknown): number | null { - if (typeof minor === 'number') return minor; - if (legacyMajor === null || legacyMajor === undefined) return null; - return fromDbMoney(legacyMajor); +function safeFormatMoneyMajor( + major: string, + currency: CurrencyCode, + locale: string +): string { + try { + return formatMoney(fromDbMoney(major), currency, locale); + } catch { + return `${major} ${currency}`; + } } -function orderCurrency( - order: { currency?: string | null }, - locale: string -): CurrencyCode { - const c = order.currency ?? resolveCurrencyFromLocale(locale); - return c === 'UAH' ? 'UAH' : 'USD'; +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 formatDateTime( - value: Date | null | undefined, - locale: string -): string { - if (!value) return '-'; - return value.toLocaleString(locale); +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'); + } + + 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, + }; } -export default async function AdminOrderDetailPage({ +export default async function OrderDetailPage({ params, }: { params: Promise<{ locale: string; id: string }>; }) { const { locale, id } = await params; - - const order = await getAdminOrderDetail(id); - if (!order) notFound(); - - const canRefund = - order.paymentProvider === 'stripe' && - order.paymentStatus === 'paid' && - !!order.paymentIntentId; - - const currency = orderCurrency(order, locale); - - const totalMinor = pickMinor(order.totalAmountMinor, order.totalAmount); - const totalFormatted = - totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale); + const t = await getTranslations('shop.orders.detail'); + + const user = await getCurrentUser(); + if (!user) { + redirect( + `/${locale}/login?returnTo=${encodeURIComponent( + `/${locale}/admin/shop/orders/${id}` + )}` + ); + } + + 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, + shippingStatus: orders.shippingStatus, + 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 (rows.length === 0) notFound(); + + try { + const base = rows[0]!.order; + + const items = rows + .map(r => toOrderItem(r.item)) + .filter((i): i is NonNullable => i !== null); + + order = { + ...base, + createdAt: base.createdAt.toISOString(), + updatedAt: base.updatedAt.toISOString(), + restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null, + items, + }; + } catch (error) { + logError('Admin order detail page failed', error); + throw new Error('ORDER_DETAIL_LOAD_FAILED'); + } + + const currency: CurrencyCode = order.currency === 'UAH' ? 'UAH' : 'USD'; + const dtf = new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }); + + const totalFormatted = safeFormatMoneyMajor( + order.totalAmount, + currency, + locale + ); + const createdFormatted = safeFormatDateTime(order.createdAt, dtf); + const restockedFormatted = order.restockedAt + ? safeFormatDateTime(order.restockedAt, dtf) + : '—'; + const NAV_LINK = cn(SHOP_NAV_LINK_BASE, 'text-lg', SHOP_FOCUS); + + const PRODUCT_LINK = cn( + SHOP_LINK_BASE, + SHOP_LINK_MD, + SHOP_FOCUS, + 'truncate', + '!underline !decoration-2 !underline-offset-4' + ); return ( -
-
+
+
-

- Order +

+ {t('title')}

-

+

{order.id} -

+
-
- - Back +
+ + {t('shop')} + +
-
-

- Summary -

- -
-
-
Payment status
-
{order.paymentStatus}
-
- -
-
Total
-
{totalFormatted}
-
- -
-
Provider
-
{order.paymentProvider}
-
- -
-
Payment intent
-
- {order.paymentIntentId ?? '-'} -
-
- -
-
Idempotency key
-
- {order.idempotencyKey} -
-
-
-
- -
-

- Stock / timestamps -

+

+ {t('orderSummary')} +

-
-
-
Created
-
- {formatDateTime(order.createdAt, locale)} -
-
- -
-
Updated
-
- {formatDateTime(order.updatedAt, locale)} -
-
- -
-
Stock restored
-
- {order.stockRestored ? 'Yes' : 'No'} -
-
- -
-
Restocked at
-
- {formatDateTime(order.restockedAt, locale)} -
-
-
-
+
+
+
{t('total')}
+
{totalFormatted}
+
+ +
+
+ {t('paymentStatus')} +
+
+ {String(order.paymentStatus)} +
+
+ +
+
+ {t('shippingStatus')} +
+
+ {order.shippingStatus ?? '-'} +
+
+ +
+
+ {t('trackingNumber')} +
+
+ {order.trackingNumber ?? '-'} +
+
+ +
+
{t('created')}
+
{createdFormatted}
+
+ +
+
{t('provider')}
+
{String(order.paymentProvider)}
+
+
+ +
+
+
+ {t('paymentReference')} +
+
+ {order.paymentIntentId ?? '—'} +
+
+
+
+ {t('idempotencyKey')} +
+
{order.idempotencyKey}
+
+
+ +
+
+
+ {t('stockRestored')} +
+
+ {order.stockRestored ? t('yes') : t('no')} +
+
+
+
+ {t('restockedAt')} +
+
{restockedFormatted}
+
+
-
-

- Order items -

- -
- - - - - - - - - - - - - - {order.items.map(item => { - const unitMinor = pickMinor( - item.unitPriceMinor, - item.unitPrice - ); - const lineMinor = pickMinor( - item.lineTotalMinor, - item.lineTotal - ); - - const unitFormatted = - unitMinor === null - ? '-' - : formatMoney(unitMinor, currency, locale); - - const lineFormatted = - lineMinor === null - ? '-' - : formatMoney(lineMinor, currency, locale); - - return ( - - - - - - - - - - ); - })} - - {order.items.length === 0 ? ( - - - - ) : null} - -
Line items for this order
- Product - - Qty - - Unit - - Line total -
-
- {item.productTitle ?? '-'} -
-
- - {item.productSlug ?? '-'} - - {item.productSku ? ( - · {item.productSku} - ) : null} -
-
- {item.quantity} - - {unitFormatted} - - {lineFormatted} -
- No items found for this order. -
+
+
+

+ {t('items')} +

+ +
    + {order.items.map(it => ( +
  • +
    +
    + {it.productSlug ? ( + + {it.productTitle ?? + it.productSlug ?? + it.productSku ?? + it.productId} + + ) : ( +
    + {it.productTitle ?? it.productSku ?? it.productId} +
    + )} + +
    + {it.productSku + ? t('sku', { sku: it.productSku }) + : t('product', { productId: it.productId })} +
    +
    + +
    +
    +
    {t('quantity')}
    +
    Qty: {it.quantity}
    +
    +
    +
    {t('unitPrice')}
    +
    + Unit:{' '} + {safeFormatMoneyMajor(it.unitPrice, currency, locale)} +
    +
    +
    +
    {t('lineTotal')}
    +
    + Line:{' '} + {safeFormatMoneyMajor(it.lineTotal, currency, locale)} +
    +
    +
    +
    +
  • + ))} +
); diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 2f2ef7ed..5bffd616 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -9,8 +9,14 @@ import { useEffect, useState } from 'react'; import { Loader } from '@/components/shared/Loader'; import { useCart } from '@/components/shop/CartProvider'; import { Link, useRouter } from '@/i18n/routing'; +import { + buildCheckoutShippingPayload, + type CheckoutDeliveryMethodCode, + type ShippingAvailabilityReasonCode, +} from '@/lib/services/shop/shipping/checkout-payload'; import { formatMoney } from '@/lib/shop/currency'; import { generateIdempotencyKey } from '@/lib/shop/idempotency'; +import { localeToCountry } from '@/lib/shop/locale'; import { SHOP_CHIP_BORDER_HOVER, SHOP_CHIP_INTERACTIVE, @@ -89,6 +95,29 @@ type OrdersSummaryState = { latestOrderId: string | null; }; +type ShippingMethod = { + provider: 'nova_poshta'; + methodCode: CheckoutDeliveryMethodCode; + title: string; +}; + +type ShippingCity = { + ref: string; + nameUa: string; +}; + +type ShippingWarehouse = { + ref: string; + name: string; + address: string | null; +}; + +function isWarehouseMethod( + methodCode: CheckoutDeliveryMethodCode | null +): boolean { + return methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER'; +} + export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); @@ -114,6 +143,40 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { }) ); const [isClientReady, setIsClientReady] = useState(false); + const [shippingMethods, setShippingMethods] = useState([]); + const [shippingMethodsLoading, setShippingMethodsLoading] = useState(true); + const [shippingAvailable, setShippingAvailable] = useState(true); + const [shippingReasonCode, setShippingReasonCode] = + useState(null); + const [selectedShippingMethod, setSelectedShippingMethod] = + useState(null); + + const [cityQuery, setCityQuery] = useState(''); + const [cityOptions, setCityOptions] = useState([]); + const [selectedCityRef, setSelectedCityRef] = useState(null); + const [selectedCityName, setSelectedCityName] = useState(null); + const [citiesLoading, setCitiesLoading] = useState(false); + + const [warehouseQuery, setWarehouseQuery] = useState(''); + const [warehouseOptions, setWarehouseOptions] = useState( + [] + ); + const [selectedWarehouseRef, setSelectedWarehouseRef] = useState< + string | null + >(null); + const [selectedWarehouseName, setSelectedWarehouseName] = useState< + string | null + >(null); + const [warehousesLoading, setWarehousesLoading] = useState(false); + + const [courierAddressLine1, setCourierAddressLine1] = useState(''); + const [courierAddressLine2, setCourierAddressLine2] = useState(''); + const [recipientName, setRecipientName] = useState(''); + const [recipientPhone, setRecipientPhone] = useState(''); + const [recipientEmail, setRecipientEmail] = useState(''); + const [recipientComment, setRecipientComment] = useState(''); + + const [deliveryUiError, setDeliveryUiError] = useState(null); useEffect(() => { setIsClientReady(true); @@ -126,6 +189,63 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const canUseStripe = stripeEnabled; const canUseMonobank = monobankEnabled && isUahCheckout; const hasSelectableProvider = canUseStripe || canUseMonobank; + const country = localeToCountry(locale); + const shippingUnavailableHardBlock = + shippingReasonCode === 'SHOP_SHIPPING_DISABLED' || + shippingReasonCode === 'NP_DISABLED' || + shippingReasonCode === 'COUNTRY_NOT_SUPPORTED' || + shippingReasonCode === 'CURRENCY_NOT_SUPPORTED' || + shippingReasonCode === 'INTERNAL_ERROR'; + const isWarehouseSelectionMethod = isWarehouseMethod(selectedShippingMethod); + const safeT = (key: string, fallback: string) => { + try { + return t(key as any); + } catch { + return fallback; + } + }; + + const SHIPPING_AVAILABILITY_REASON_TO_T_KEY: Record = { + SHOP_SHIPPING_DISABLED: 'delivery.unavailable.shopShippingDisabled', + NP_DISABLED: 'delivery.unavailable.npDisabled', + COUNTRY_NOT_SUPPORTED: 'delivery.unavailable.countryNotSupported', + CURRENCY_NOT_SUPPORTED: 'delivery.unavailable.currencyNotSupported', + INTERNAL_ERROR: 'delivery.unavailable.internalError', + }; + + const resolveShippingUnavailableText = ( + code: ShippingAvailabilityReasonCode | null + ): string | null => { + if (!code || code === 'OK') return null; + const key = + SHIPPING_AVAILABILITY_REASON_TO_T_KEY[String(code)] ?? + 'delivery.unavailableFallback'; + return safeT(key, String(code)); + }; + + const SHIPPING_PAYLOAD_ERROR_CODE_TO_T_KEY: Record = { + SHIPPING_METHOD_REQUIRED: 'delivery.validation.methodRequired', + CITY_REQUIRED: 'delivery.validation.cityRequired', + WAREHOUSE_REQUIRED: 'delivery.validation.warehouseRequired', + ADDRESS_REQUIRED: 'delivery.validation.addressRequired', + RECIPIENT_NAME_REQUIRED: 'delivery.validation.recipientNameRequired', + RECIPIENT_PHONE_REQUIRED: 'delivery.validation.recipientPhoneRequired', + RECIPIENT_EMAIL_INVALID: 'delivery.validation.recipientEmailInvalid', + }; + + const resolveShippingPayloadErrorText = (result: unknown): string => { + const code = + result && typeof result === 'object' && 'code' in result + ? typeof (result as any).code === 'string' + ? String((result as any).code) + : null + : null; + + const key = code ? SHIPPING_PAYLOAD_ERROR_CODE_TO_T_KEY[code] : null; + + if (key) return safeT(key, code ?? 'SHIPPING_INVALID'); + return safeT('delivery.validation.invalid', code ?? 'SHIPPING_INVALID'); + }; useEffect(() => { if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { @@ -137,6 +257,299 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } }, [canUseMonobank, canUseStripe, selectedProvider]); + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + async function loadShippingMethods() { + setShippingMethodsLoading(true); + setDeliveryUiError(null); + + try { + const qs = new URLSearchParams({ + locale, + currency: cart.summary.currency, + ...(country ? { country } : {}), + }); + + const response = await fetch(`/api/shop/shipping/methods?${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + if (!cancelled) { + setShippingAvailable(false); + setShippingReasonCode('INTERNAL_ERROR'); + setShippingMethods([]); + } + return; + } + + const VALID_REASON_CODES = new Set([ + 'OK', + 'SHOP_SHIPPING_DISABLED', + 'NP_DISABLED', + 'COUNTRY_NOT_SUPPORTED', + 'CURRENCY_NOT_SUPPORTED', + 'INTERNAL_ERROR', + ]); + + const hardBlock = () => { + setShippingAvailable(false); + setShippingReasonCode('INTERNAL_ERROR'); + setShippingMethods([]); + setSelectedShippingMethod(null); + }; + + if (cancelled) return; + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + hardBlock(); + return; + } + + const obj = data as Record; + + if (typeof obj.available !== 'boolean') { + hardBlock(); + return; + } + + const available = obj.available; + + const reasonRaw = obj.reasonCode; + const reasonCode = + typeof reasonRaw === 'string' && VALID_REASON_CODES.has(reasonRaw) + ? (reasonRaw as ShippingAvailabilityReasonCode) + : null; + + const methodsRaw = obj.methods; + if (!Array.isArray(methodsRaw)) { + hardBlock(); + return; + } + + const methods: ShippingMethod[] = []; + for (const item of methodsRaw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + hardBlock(); + return; + } + const m = item as Record; + + const providerOk = m.provider === 'nova_poshta'; + const methodCodeOk = + typeof m.methodCode === 'string' && m.methodCode.trim().length > 0; + const titleOk = + typeof m.title === 'string' && m.title.trim().length > 0; + + if (!providerOk || !methodCodeOk || !titleOk) { + hardBlock(); + return; + } + + methods.push({ + provider: 'nova_poshta', + methodCode: m.methodCode as CheckoutDeliveryMethodCode, + title: String(m.title), + }); + } + + if (available === false && reasonCode == null) { + hardBlock(); + return; + } + + setShippingAvailable(available); + setShippingReasonCode(reasonCode); + setShippingMethods(methods); + + if (!available || methods.length === 0) { + setSelectedShippingMethod(null); + return; + } + + setSelectedShippingMethod(current => { + if ( + current && + methods.some(method => method.methodCode === current) + ) { + return current; + } + return methods[0]!.methodCode; + }); + } catch { + if (!cancelled) { + setShippingAvailable(false); + setShippingReasonCode('INTERNAL_ERROR'); + setShippingMethods([]); + } + } finally { + if (!cancelled) { + setShippingMethodsLoading(false); + } + } + } + + void loadShippingMethods(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [cart.summary.currency, country, locale]); + + useEffect(() => { + setSelectedWarehouseRef(null); + setSelectedWarehouseName(null); + setWarehouseOptions([]); + setWarehouseQuery(''); + }, [selectedCityRef, selectedShippingMethod]); + + useEffect(() => { + if (!shippingAvailable) { + setCityOptions([]); + setCitiesLoading(false); + return; + } + + const query = cityQuery.trim(); + if (query.length < 2) { + setCityOptions([]); + setCitiesLoading(false); + return; + } + + let cancelled = false; + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + setCitiesLoading(true); + try { + const qs = new URLSearchParams({ + q: query, + locale, + currency: cart.summary.currency, + ...(country ? { country } : {}), + }); + + const response = await fetch(`/api/shop/shipping/np/cities?${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + }); + + const data = await response.json().catch(() => null); + + if (!response.ok || !data || data.available === false) { + if (!cancelled) { + setCityOptions([]); + } + return; + } + + if (!cancelled) { + const next = Array.isArray(data.items) + ? (data.items as ShippingCity[]) + : []; + setCityOptions(next); + } + } catch { + if (!cancelled) { + setCityOptions([]); + } + } finally { + if (!cancelled) { + setCitiesLoading(false); + } + } + }, 250); + + return () => { + cancelled = true; + controller.abort(); + clearTimeout(timeoutId); + }; + }, [cart.summary.currency, cityQuery, country, locale, shippingAvailable]); + + useEffect(() => { + if (!shippingAvailable || !selectedCityRef || !isWarehouseSelectionMethod) { + setWarehouseOptions([]); + setWarehousesLoading(false); + return; + } + + let cancelled = false; + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + setWarehousesLoading(true); + try { + const qs = new URLSearchParams({ + cityRef: selectedCityRef, + locale, + currency: cart.summary.currency, + ...(country ? { country } : {}), + ...(warehouseQuery.trim().length > 0 + ? { q: warehouseQuery.trim() } + : {}), + }); + + const response = await fetch( + `/api/shop/shipping/np/warehouses?${qs.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + } + ); + + const data = await response.json().catch(() => null); + + if (!response.ok || !data || data.available === false) { + if (!cancelled) { + setWarehouseOptions([]); + } + return; + } + + if (!cancelled) { + const next = Array.isArray(data.items) + ? (data.items as ShippingWarehouse[]) + : []; + setWarehouseOptions(next); + } + } catch { + if (!cancelled) { + setWarehouseOptions([]); + } + } finally { + if (!cancelled) { + setWarehousesLoading(false); + } + } + }, 250); + + return () => { + cancelled = true; + controller.abort(); + clearTimeout(timeoutId); + }; + }, [ + cart.summary.currency, + country, + isWarehouseSelectionMethod, + locale, + selectedCityRef, + shippingAvailable, + warehouseQuery, + ]); + useEffect(() => { let cancelled = false; const controller = new AbortController(); @@ -315,12 +728,49 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); return; } + if (shippingMethodsLoading) { + setCheckoutError(safeT('delivery.methodsLoading', 'METHODS_LOADING')); + return; + } + + if (shippingUnavailableHardBlock) { + setCheckoutError( + resolveShippingUnavailableText(shippingReasonCode) ?? + safeT('delivery.unavailableFallback', 'SHIPPING_UNAVAILABLE') + ); + return; + } setCheckoutError(null); + setDeliveryUiError(null); setCreatedOrderId(null); setIsCheckingOut(true); try { + const shippingPayloadResult = shippingAvailable + ? buildCheckoutShippingPayload({ + shippingAvailable, + reasonCode: shippingReasonCode, + locale, + methodCode: selectedShippingMethod, + cityRef: selectedCityRef, + warehouseRef: selectedWarehouseRef, + addressLine1: courierAddressLine1, + addressLine2: courierAddressLine2, + recipientFullName: recipientName, + recipientPhone: recipientPhone, + recipientEmail, + recipientComment, + }) + : null; + + if (shippingPayloadResult && !shippingPayloadResult.ok) { + const msg = resolveShippingPayloadErrorText(shippingPayloadResult); + setDeliveryUiError(msg); + setCheckoutError(msg); + return; + } + const idempotencyKey = generateIdempotencyKey(); const response = await fetch('/api/shop/checkout', { @@ -331,6 +781,14 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { }, body: JSON.stringify({ paymentProvider: selectedProvider, + ...(shippingPayloadResult?.ok + ? { + shipping: shippingPayloadResult.shipping, + ...(shippingPayloadResult.country + ? { country: shippingPayloadResult.country } + : {}), + } + : {}), items: cart.items.map(item => ({ productId: item.productId, quantity: item.quantity, @@ -406,6 +864,14 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } } + const shippingUnavailableText = + resolveShippingUnavailableText(shippingReasonCode); + const canPlaceOrder = + hasSelectableProvider && + !shippingMethodsLoading && + !shippingUnavailableHardBlock && + (!shippingAvailable || !!selectedShippingMethod); + const ordersCard = ordersSummary ? (
@@ -675,7 +1141,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { {t('summary.shipping')} - {t('summary.shippingCalc')} + {t('summary.shippingInformationalOnly')}
@@ -693,8 +1159,289 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
+ +

+ {t('summary.shippingPayOnDeliveryNote')} +

+
+ + {t('delivery.legend')} + + + {shippingMethodsLoading ? ( +

+ {t('delivery.methodsLoading')} +

+ ) : null} + + {!shippingMethodsLoading && !shippingAvailable ? ( +

+ {shippingUnavailableText ?? t('delivery.unavailableFallback')} +

+ ) : null} + + {shippingAvailable ? ( +
+
+ {shippingMethods.map(method => ( + + ))} +
+ +
+ + { + setCityQuery(event.target.value); + setSelectedCityRef(null); + setSelectedCityName(null); + }} + placeholder={t('delivery.city.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> + + {selectedCityRef ? ( +

+ {t('delivery.city.selected', { + city: selectedCityName ?? selectedCityRef, + })} +

+ ) : null} + + {citiesLoading ? ( +

+ {t('delivery.city.searching')} +

+ ) : null} + + {!citiesLoading && cityOptions.length > 0 ? ( +
+ {cityOptions.map(city => ( + + ))} +
+ ) : null} +
+ + {isWarehouseSelectionMethod ? ( +
+ {citiesLoading ? ( +

+ {t('delivery.city.searching')} +

+ ) : null} + + + + { + setWarehouseQuery(event.target.value); + setSelectedWarehouseRef(null); + setSelectedWarehouseName(null); + }} + placeholder={t('delivery.warehouse.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + disabled={!selectedCityRef} + /> + + {selectedWarehouseRef ? ( +

+ {t('delivery.warehouse.selected', { + warehouse: + selectedWarehouseName ?? selectedWarehouseRef, + })} +

+ ) : null} + + {warehousesLoading ? ( +

+ {t('delivery.warehouse.searching')} +

+ ) : null} + + {!warehousesLoading && warehouseOptions.length > 0 ? ( +
+ {warehouseOptions.map(warehouse => ( + + ))} +
+ ) : null} +
+ ) : null} + + {selectedShippingMethod === 'NP_COURIER' ? ( +
+ + + setCourierAddressLine1(event.target.value) + } + placeholder={t( + 'delivery.courierAddress.line1Placeholder' + )} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> + + setCourierAddressLine2(event.target.value) + } + placeholder={t( + 'delivery.courierAddress.line2Placeholder' + )} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ ) : null} + +
+ + setRecipientName(event.target.value)} + placeholder={t('delivery.recipientName.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ +
+ + setRecipientPhone(event.target.value)} + placeholder={t('delivery.recipientPhone.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ +
+ + setRecipientEmail(event.target.value)} + placeholder={t('delivery.recipientEmail.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ +
+ +