diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 30b66fab..6606fdb5 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react'; +import { Check, Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; @@ -18,57 +18,30 @@ 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, - SHOP_CHIP_SHADOW_HOVER, - SHOP_CTA_BASE, + SHOP_CART_CARD, + SHOP_CART_FIELD, + SHOP_CART_HERO_CTA, + SHOP_CART_METHOD_CARD_DESCRIPTION, + SHOP_CART_METHOD_CARD_TITLE, + SHOP_CART_ORDERS_COUNT_BADGE, + SHOP_CART_ORDERS_LINK, + SHOP_CART_SECTION_HEADER, + SHOP_CART_SELECTABLE_CARD, + SHOP_CART_SELECTABLE_CARD_CHECK, + SHOP_CART_SELECTABLE_CARD_COMPACT, + SHOP_CART_SELECTABLE_CARD_IDLE, + SHOP_CART_SELECTABLE_CARD_SELECTED, + SHOP_CART_SELECTABLE_CARD_TALL, SHOP_CTA_INSET, - SHOP_CTA_INTERACTIVE, SHOP_CTA_WAVE, SHOP_DISABLED, SHOP_FOCUS, SHOP_LINK_BASE, - SHOP_LINK_MD, SHOP_LINK_XS, - SHOP_STEPPER_BUTTON_BASE, shopCtaGradient, } from '@/lib/shop/ui-classes'; import { cn } from '@/lib/utils'; -const SHOP_PRODUCT_LINK = cn( - 'block truncate', - SHOP_LINK_BASE, - SHOP_LINK_MD, - SHOP_FOCUS -); - -const SHOP_STEPPER_BTN = cn( - SHOP_STEPPER_BUTTON_BASE, - 'h-8 w-8', - SHOP_CHIP_INTERACTIVE, - SHOP_CHIP_SHADOW_HOVER, - SHOP_CHIP_BORDER_HOVER, - SHOP_FOCUS, - SHOP_DISABLED -); - -const SHOP_HERO_CTA = cn( - SHOP_CTA_BASE, - SHOP_CTA_INTERACTIVE, - SHOP_FOCUS, - SHOP_DISABLED, - 'w-full justify-center gap-2 px-6 py-3 text-sm text-white', - 'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]' -); - -const ORDERS_LINK = cn(SHOP_LINK_BASE, SHOP_LINK_MD, SHOP_FOCUS); - -const ORDERS_COUNT_BADGE = cn( - 'border-border bg-muted/40 text-foreground inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold tabular-nums' -); - -const ORDERS_CARD = cn('border-border rounded-md border p-4'); - type Props = { stripeEnabled: boolean; monobankEnabled: boolean; @@ -81,6 +54,29 @@ type CheckoutPaymentMethod = | 'monobank_invoice' | 'monobank_google_pay'; +type OrdersSummaryState = { + count: number; + 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; + isPostMachine: boolean; +}; + function resolveInitialProvider(args: { stripeEnabled: boolean; monobankEnabled: boolean; @@ -106,30 +102,32 @@ function resolveDefaultMethodForProvider(args: { return null; } -type OrdersSummaryState = { - count: number; - latestOrderId: string | null; -}; +function normalizeLookupValue(value: string): string { + return value.trim().toLocaleLowerCase(); +} -type ShippingMethod = { - provider: 'nova_poshta'; - methodCode: CheckoutDeliveryMethodCode; - title: string; -}; +function normalizeExternalRecoveryUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; -type ShippingCity = { - ref: string; - nameUa: string; -}; + const trimmed = value.trim(); -type ShippingWarehouse = { - ref: string; - name: string; - address: string | null; -}; + if (trimmed.length === 0) return null; -function normalizeLookupValue(value: string): string { - return value.trim().toLocaleLowerCase(); + if (/[\u0000-\u001F\u007F]/.test(trimmed)) { + return null; + } + + try { + const url = new URL(trimmed); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + return url.toString(); + } catch { + return null; + } } function normalizeShippingCity(raw: unknown): ShippingCity | null { @@ -163,7 +161,31 @@ function normalizeShippingCity(raw: unknown): ShippingCity | null { nameUa, }; } +function normalizeShippingWarehouse(raw: unknown): ShippingWarehouse | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const item = raw as Record; + + const ref = typeof item.ref === 'string' ? item.ref.trim() : ''; + const name = typeof item.name === 'string' ? item.name.trim() : ''; + const address = + typeof item.address === 'string' ? item.address.trim() || null : null; + const isPostMachine = + typeof item.isPostMachine === 'boolean' ? item.isPostMachine : null; + if (!ref || !name || isPostMachine === null) { + return null; + } + + return { + ref, + name, + address, + isPostMachine, + }; +} function parseShippingCitiesResponse(data: unknown): { available: boolean | null; items: ShippingCity[]; @@ -195,12 +217,143 @@ function parseShippingCitiesResponse(data: unknown): { }; } +const VALID_DELIVERY_METHOD_CODES = new Set([ + 'NP_WAREHOUSE', + 'NP_LOCKER', + 'NP_COURIER', +]); + +function isValidDeliveryMethodCode( + value: unknown +): value is CheckoutDeliveryMethodCode { + return ( + typeof value === 'string' && + VALID_DELIVERY_METHOD_CODES.has(value as CheckoutDeliveryMethodCode) + ); +} + function isWarehouseMethod( methodCode: CheckoutDeliveryMethodCode | null ): boolean { return methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER'; } +function resolveShippingMethodCardCopy(args: { + methodCode: CheckoutDeliveryMethodCode; + fallbackTitle: string; + safeT: (key: string, fallback: string) => string; +}): { title: string; description: string } { + const { methodCode, fallbackTitle, safeT } = args; + + switch (methodCode) { + case 'NP_WAREHOUSE': + return { + title: safeT('delivery.methodCards.warehouse.title', fallbackTitle), + description: safeT( + 'delivery.methodCards.warehouse.description', + 'Pick up at a Nova Poshta branch' + ), + }; + + case 'NP_LOCKER': + return { + title: safeT('delivery.methodCards.locker.title', fallbackTitle), + description: safeT( + 'delivery.methodCards.locker.description', + 'Pick up from a Nova Poshta parcel locker' + ), + }; + + case 'NP_COURIER': + return { + title: safeT('delivery.methodCards.courier.title', fallbackTitle), + description: safeT( + 'delivery.methodCards.courier.description', + 'Nova Poshta door-to-door delivery' + ), + }; + + default: + return { + title: fallbackTitle, + description: '', + }; + } +} +function StepIndicator({ + step, + label, + completed, + active, +}: { + step: number; + label: string; + completed: boolean; + active: boolean; +}) { + return ( +
+
+ {completed ? : step} +
+ + + {label} + +
+ ); +} + +function SelectableCard({ + selected, + disabled = false, + children, + size = 'tall', +}: { + selected: boolean; + disabled?: boolean; + children: React.ReactNode; + size?: 'tall' | 'compact'; +}) { + return ( + + ); +} export default function CartPage({ stripeEnabled, monobankEnabled, @@ -215,6 +368,12 @@ export default function CartPage({ const [isCheckingOut, setIsCheckingOut] = useState(false); const [checkoutError, setCheckoutError] = useState(null); const [createdOrderId, setCreatedOrderId] = useState(null); + const [createdOrderStatusToken, setCreatedOrderStatusToken] = useState< + string | null + >(null); + const [paymentRecoveryUrl, setPaymentRecoveryUrl] = useState( + null + ); const [ordersSummary, setOrdersSummary] = useState( null @@ -226,8 +385,10 @@ export default function CartPage({ monobankEnabled, currency: cart?.summary?.currency, }); + const [selectedProvider, setSelectedProvider] = useState(initialProvider); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(() => resolveDefaultMethodForProvider({ @@ -235,6 +396,7 @@ export default function CartPage({ currency: cart?.summary?.currency, }) ); + const [isClientReady, setIsClientReady] = useState(false); const [shippingMethods, setShippingMethods] = useState([]); const [shippingMethodsLoading, setShippingMethodsLoading] = useState(true); @@ -285,13 +447,16 @@ export default function CartPage({ const canUseMonobankGooglePay = canUseMonobank && monobankGooglePayEnabled; 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); @@ -341,6 +506,7 @@ export default function CartPage({ if (key) return safeT(key, code ?? 'SHIPPING_INVALID'); return safeT('delivery.validation.invalid', code ?? 'SHIPPING_INVALID'); }; + const clearCheckoutUiErrors = () => { setDeliveryUiError(null); setCheckoutError(null); @@ -468,16 +634,19 @@ export default function CartPage({ } 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 methodCode = + typeof m.methodCode === 'string' ? m.methodCode.trim() : ''; + const methodCodeOk = isValidDeliveryMethodCode(methodCode); const titleOk = typeof m.title === 'string' && m.title.trim().length > 0; @@ -488,7 +657,7 @@ export default function CartPage({ methods.push({ provider: 'nova_poshta', - methodCode: m.methodCode as CheckoutDeliveryMethodCode, + methodCode, title: String(m.title), }); } @@ -553,6 +722,7 @@ export default function CartPage({ } const query = cityQuery.trim(); + if (query.length < 2) { setCityOptions([]); setCityLookupFailed(false); @@ -640,8 +810,10 @@ export default function CartPage({ let cancelled = false; const controller = new AbortController(); + const timeoutId = setTimeout(async () => { setWarehousesLoading(true); + try { const qs = new URLSearchParams({ cityRef: selectedCityRef, @@ -673,10 +845,27 @@ export default function CartPage({ } if (!cancelled) { - const next = Array.isArray(data.items) - ? (data.items as ShippingWarehouse[]) + const itemsRaw: unknown[] = Array.isArray(data.items) + ? data.items : []; - setWarehouseOptions(next); + + const next: ShippingWarehouse[] = itemsRaw + .map((item: unknown) => normalizeShippingWarehouse(item)) + .filter( + (item: ShippingWarehouse | null): item is ShippingWarehouse => + item !== null + ); + + const filtered: ShippingWarehouse[] = + selectedShippingMethod === 'NP_LOCKER' + ? next.filter( + (warehouse: ShippingWarehouse) => warehouse.isPostMachine + ) + : next.filter( + (warehouse: ShippingWarehouse) => !warehouse.isPostMachine + ); + + setWarehouseOptions(filtered); } } catch { if (!cancelled) { @@ -700,6 +889,7 @@ export default function CartPage({ isWarehouseSelectionMethod, locale, selectedCityRef, + selectedShippingMethod, shippingAvailable, warehouseQuery, ]); @@ -838,7 +1028,7 @@ export default function CartPage({ setOrdersSummary({ count, latestOrderId }); } } catch { - // ignore (timeout/network) — we just don't show summary + // ignore } finally { clearTimeout(timeoutId); if (!cancelled) { @@ -858,6 +1048,7 @@ export default function CartPage({ const translateColor = (color: string | null | undefined): string | null => { if (!color) return null; const colorSlug = color.toLowerCase(); + try { return tColors(colorSlug); } catch { @@ -870,10 +1061,12 @@ export default function CartPage({ setCheckoutError(t('checkout.paymentMethod.noAvailable')); return; } + if (selectedProvider === 'stripe' && !canUseStripe) { setCheckoutError(t('checkout.paymentMethod.noAvailable')); return; } + if (selectedProvider === 'monobank' && !canUseMonobank) { setCheckoutError( monobankEnabled @@ -882,6 +1075,7 @@ export default function CartPage({ ); return; } + if ( selectedProvider === 'monobank' && selectedPaymentMethod === 'monobank_google_pay' && @@ -892,6 +1086,7 @@ export default function CartPage({ ); return; } + if (shippingMethodsLoading) { setCheckoutError(safeT('delivery.methodsLoading', 'METHODS_LOADING')); return; @@ -908,6 +1103,8 @@ export default function CartPage({ setCheckoutError(null); setDeliveryUiError(null); setCreatedOrderId(null); + setCreatedOrderStatusToken(null); + setPaymentRecoveryUrl(null); setIsCheckingOut(true); try { @@ -938,10 +1135,12 @@ export default function CartPage({ const idempotencyKey = generateIdempotencyKey(); const checkoutPaymentMethod = selectedProvider === 'stripe' ? 'stripe_card' : selectedPaymentMethod; + if (!checkoutPaymentMethod) { setCheckoutError(t('checkout.paymentMethod.noAvailable')); return; } + const response = await fetch('/api/shop/checkout', { method: 'POST', headers: { @@ -992,10 +1191,7 @@ export default function CartPage({ data.clientSecret.trim().length > 0 ? data.clientSecret : null; - const monobankPageUrl: string | null = - typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0 - ? data.pageUrl - : null; + const monobankPageUrl = normalizeExternalRecoveryUrl(data.pageUrl); const statusToken: string | null = typeof data.statusToken === 'string' && data.statusToken.trim().length > 0 @@ -1004,28 +1200,47 @@ export default function CartPage({ const orderId = String(data.orderId); setCreatedOrderId(orderId); + setCreatedOrderStatusToken(statusToken); - if (paymentProvider === 'stripe' && clientSecret) { - router.push( - `${shopBase}/checkout/payment/${encodeURIComponent( - orderId - )}?clientSecret=${encodeURIComponent(clientSecret)}&clearCart=1` - ); + const statusTokenQuery = statusToken + ? `&statusToken=${encodeURIComponent(statusToken)}` + : ''; + + if (selectedProvider === 'stripe') { + if (paymentProvider !== 'stripe' || !clientSecret) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } + + const stripeRecoveryUrl = `${shopBase}/checkout/payment/${encodeURIComponent( + orderId + )}?clientSecret=${encodeURIComponent(clientSecret)}${statusTokenQuery}`; + + setPaymentRecoveryUrl(stripeRecoveryUrl); + + router.push(`${stripeRecoveryUrl}&clearCart=1`); return; } - if (paymentProvider === 'monobank') { + if (selectedProvider === 'monobank') { + if (paymentProvider !== 'monobank') { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } + if (checkoutPaymentMethod === 'monobank_google_pay') { if (!statusToken) { setCheckoutError(t('checkout.errors.unexpectedResponse')); return; } - router.push( - `${shopBase}/checkout/payment/monobank/${encodeURIComponent( - orderId - )}?statusToken=${encodeURIComponent(statusToken)}&clearCart=1` - ); + const monobankGooglePayRecoveryUrl = `${shopBase}/checkout/payment/monobank/${encodeURIComponent( + orderId + )}?statusToken=${encodeURIComponent(statusToken)}`; + + setPaymentRecoveryUrl(monobankGooglePayRecoveryUrl); + + router.push(`${monobankGooglePayRecoveryUrl}&clearCart=1`); return; } @@ -1034,20 +1249,42 @@ export default function CartPage({ return; } + setPaymentRecoveryUrl(monobankPageUrl); window.location.assign(monobankPageUrl); return; } - const paymentsDisabledFlag = - paymentProvider !== 'stripe' || !clientSecret - ? '&paymentsDisabled=true' - : ''; + if (process.env.NODE_ENV !== 'production') { + const g = globalThis as unknown as { + __DEVLOVERS_SHOP_DEBUG_LOGS__?: Array<{ + level: 'warn'; + message: string; + meta: Record; + ts: number; + }>; + }; - router.push( - `${shopBase}/checkout/success?orderId=${encodeURIComponent( - orderId - )}&clearCart=1${paymentsDisabledFlag}` - ); + if (!g.__DEVLOVERS_SHOP_DEBUG_LOGS__) { + g.__DEVLOVERS_SHOP_DEBUG_LOGS__ = []; + } + + g.__DEVLOVERS_SHOP_DEBUG_LOGS__.push({ + level: 'warn', + message: '[shop.cart] blocked unexpected checkout provider fallback', + meta: { + selectedProvider, + paymentProvider, + orderId, + checkoutPaymentMethod, + hasClientSecret: !!clientSecret, + hasStatusToken: !!statusToken, + }, + ts: Date.now(), + }); + } + + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; } catch { setCheckoutError(t('checkout.errors.startFailed')); } finally { @@ -1057,6 +1294,7 @@ export default function CartPage({ const shippingUnavailableText = resolveShippingUnavailableText(shippingReasonCode); + const hasValidPaymentSelection = selectedProvider === 'stripe' ? canUseStripe && selectedPaymentMethod === 'stripe_card' @@ -1064,6 +1302,7 @@ export default function CartPage({ (selectedPaymentMethod === 'monobank_invoice' || (selectedPaymentMethod === 'monobank_google_pay' && canUseMonobankGooglePay)); + const canPlaceOrder = hasSelectableProvider && hasValidPaymentSelection && @@ -1071,10 +1310,29 @@ export default function CartPage({ !shippingUnavailableHardBlock && (!shippingAvailable || !!selectedShippingMethod); + const orderDetailsHref = createdOrderId + ? `/shop/orders/${encodeURIComponent(createdOrderId)}${ + createdOrderStatusToken + ? `?statusToken=${encodeURIComponent(createdOrderStatusToken)}` + : '' + }` + : null; + const recoveryHref = paymentRecoveryUrl ?? orderDetailsHref; + const recoveryIsExternal = + !!recoveryHref && /^https?:\/\//i.test(recoveryHref); + const recoveryLinkLabel = paymentRecoveryUrl + ? t('checkout.notRedirected') + : t('checkout.goToOrder'); + + const itemsComplete = cart.items.length > 0; + const deliveryComplete = + !shippingAvailable || !!selectedShippingMethod || shippingMethodsLoading; + const paymentComplete = hasValidPaymentSelection; + const ordersCard = ordersSummary ? ( -
-
- +
+
+ {tOrders('title')} @@ -1084,28 +1342,29 @@ export default function CartPage({ aria-hidden="true" /> ) : ( - + {ordersSummary.count} )}
-

- {tOrders('subtitle')} -

- - {ordersSummary.latestOrderId ? ( -
- - {t('checkout.goToOrder')} - -
- ) : null} +
+

{tOrders('subtitle')}

+ + {ordersSummary.latestOrderId ? ( +
+ + {t('checkout.goToOrder')} + +
+ ) : null} +
) : null; + const loadingAnnouncement = (() => { try { return t('loading'); @@ -1116,7 +1375,7 @@ export default function CartPage({ if (!isClientReady) { return ( -
+
@@ -1129,19 +1388,23 @@ export default function CartPage({ if (cart.items.length === 0) { return ( -
+
-