/** * ConfirmBookingModal – switches between confirm, payment, and success views. */ import { __ } from '@wordpress/i18n'; import { memo, useCallback, useEffect, useMemo, useState, } from '@wordpress/element'; import { Card, CardBody, Flex, Modal, Notice } from '@wordpress/components'; import type { Order, Dropshop, PluginSettings, CheckoutStep, PrePayBalance, ValidationResponse, Quote, CheckoutValidateRequest, GenericPaymentResponse, PaymentOrderResponse, } from '../../../types'; import { BookingModalFooter, PaymentModal, PrepayTopUp, } from '../../../shared/components'; import DropshopModal from '../dropshop-modal'; import { useCheckoutOrder, useParcel2goAuth, usePrepay, } from '../../../shared/hooks'; import { isQuoteDDP, isQuoteDropoff } from '../utils/quoteHelpers'; import PriceSection from './price-section'; import OptionsSection from './options-section'; import IOSSSection from './ioss-section'; import ServiceSection from './service-section'; import { orderToCheckoutValidatePayload } from '../utils/orderToCheckoutValidatePayload'; import { getSiteUrl, isEUCountry } from '../../../shared/utils'; import { markOrderAsPaid } from '../utils/shippingStatus'; interface BookingManagerProps { quote: Quote | null; order: Order; settings: PluginSettings | null; quoteDropshops: Dropshop[]; checkoutStep: CheckoutStep; validationResult: ValidationResponse | null; isValidating: boolean; selectedQuoteExtras: string[]; collectionDateProp: string | null; changeCheckoutStep: (step: CheckoutStep) => void; validateOrder: ( payload: CheckoutValidateRequest ) => Promise; onBookingComplete: () => void; resetValidation: () => void; } function BookingManagerInner({ order, settings, quoteDropshops, checkoutStep, validationResult, isValidating, quote, selectedQuoteExtras, collectionDateProp, changeCheckoutStep, validateOrder, onBookingComplete, resetValidation, }: BookingManagerProps) { const { isLinked } = useParcel2goAuth(); const { fetchBalance, createTopUp, makePrepayPayment, isPrepayLoading, prepayError, } = usePrepay(); const { createOrderFromPayload, isCreatingOrder, orderError, resetOrder } = useCheckoutOrder(); const [prepayBalance, setPrepayBalance] = useState( null ); const [completeError, setCompleteError] = useState(null); const [discountCode, setDiscountCode] = useState(''); const [ioss, setIoss] = useState(null); const [selectedExtras, setSelectedExtras] = useState([]); const [collectionDate, setCollectionDate] = useState(collectionDateProp); const [ddp, setDdp] = useState(null); const [updatingMeta, setUpdatingMeta] = useState(false); const store = settings?.shipping || null; const isIOSSEligible = useMemo( () => isEUCountry(order?.shipping.country || ''), [order] ); const extras = useMemo( () => selectedExtras.map((extraKey) => (quote?.availableExtras ?? []).find((e) => e.key === extraKey) ), [selectedExtras, quote] ); const fullAmount = useMemo(() => { const gross = (validationResult?.price?.gross ?? 0) / 100; const extraGross = extras.reduce((acc, extra) => acc + (extra?.price?.gross ?? 0), 0) / 100; return gross + extraGross; }, [extras, validationResult]); useEffect(() => { if (!isLinked) return; let cancelled = false; fetchBalance().then((balance) => { if (!cancelled) setPrepayBalance(balance); }); return () => { cancelled = true; }; }, [fetchBalance, isLinked]); const handlePrepayTopupSuccess = async () => { if (!isLinked) return; const balance = await fetchBalance(); if (balance) setPrepayBalance(balance); changeCheckoutStep({ id: 'confirming' }); }; const revalidate = useCallback( ( currentExtras: string[], overrides: Partial<{ collectionDate: string | null; ddp: boolean | null; discountCode: string | null; ioss: string | null; }> ) => { if (checkoutStep.id !== 'confirming') return; const options = { collectionDate, ddp, discountCode: undefined, ioss, ...overrides, }; const payload = orderToCheckoutValidatePayload( order, settings, quote, currentExtras, options ); if (payload) validateOrder(payload); }, [ checkoutStep, order, settings, quote, collectionDate, ddp, ioss, validateOrder, ] ); const handleUpdateDiscount = useCallback( (currentExtras: string[], code?: string) => { revalidate(currentExtras, { discountCode: code }); }, [revalidate] ); const handleChangeCollectionDate = useCallback( ( date: string | null, currentExtras: string[], currentDiscountCode: string | null ) => { setCollectionDate(date); revalidate(currentExtras, { collectionDate: date, discountCode: currentDiscountCode, }); }, [revalidate] ); const handleSubmitIOSS = useCallback( (ioss: string | null) => { setIoss(ioss); revalidate(selectedQuoteExtras.concat(selectedExtras), { ioss, discountCode: discountCode || null, }); }, [revalidate, selectedQuoteExtras, selectedExtras, discountCode] ); const handleChangeDuties = useCallback( ( newDdp: boolean | null, currentExtras: string[], currentDiscountCode: string | null ) => { setDdp(newDdp); revalidate(currentExtras, { ddp: newDdp, discountCode: currentDiscountCode, }); }, [revalidate] ); const handlePaymentSuccess = useCallback( async ( response: GenericPaymentResponse, paymentOrderData: PaymentOrderResponse ) => { setCompleteError(null); setUpdatingMeta(true); if (!paymentOrderData) { setCompleteError( __( 'Payment succeeded but order data is missing. Please contact support with your order reference.', 'parcel2go-shipping' ) ); setUpdatingMeta(false); return; } const courierDetails = quote?.service != null ? { courier: quote.service.courier ?? '', serviceName: quote.service.name ?? '', serviceSlug: quote.service.slug ?? '', courierSlug: quote.service.courierSlug ?? '', } : null; try { const result = await markOrderAsPaid( [paymentOrderData.order.ref], { orderId: response.orderId, completeHash: response.completeHash, }, courierDetails ); if (!result.success) { const errorMsg = result.results?.[0]?.error || 'Unknown error'; throw new Error( `Failed to mark order as paid: ${errorMsg}` ); } changeCheckoutStep({ id: 'idle' }); resetValidation?.(); onBookingComplete?.(); } catch (error) { setCompleteError( __( 'Payment succeeded but failed to update order status. Please contact support with your order reference.', 'parcel2go-shipping' ) ); } finally { setUpdatingMeta(false); } }, [quote, changeCheckoutStep, onBookingComplete, resetValidation] ); const handlePayment = useCallback( async ( method: 'card' | 'prepay', discountCode?: string | null, createRule?: boolean ) => { const options = { discountCode, collectionDate, ddp, ioss, }; const payload = orderToCheckoutValidatePayload( order, settings, quote, selectedQuoteExtras.concat(selectedExtras), options ); const wooShop = getSiteUrl(); for (const item of payload.items) { item.metadata = { ...(item.metadata as Record | undefined), wooShop, wooOrderId: payload.ref, }; } if (!payload) { setCompleteError( __( 'Unable to build order request. Please check your addresses.', 'parcel2go-shipping' ) ); return; } const result = await createOrderFromPayload(payload); if (!result) { setCompleteError( __( 'Failed to create order. Please try again.', 'parcel2go-shipping' ) ); return; } const paymentOrder = { order: payload, orderResult: result, }; if (isLinked && method === 'prepay') { const { orderId, hash } = result ?? {}; if (!orderId || !hash) { setCompleteError( __( 'Invalid order response. Please try again.', 'parcel2go-shipping' ) ); return; } const response = await makePrepayPayment(orderId, hash); if (!response) { setCompleteError( __( 'PrePay payment failed. Please try again.', 'parcel2go-shipping' ) ); return; } return handlePaymentSuccess(response, paymentOrder); } changeCheckoutStep({ id: 'card-payment', paymentOrder, }); }, [ checkoutStep, order, settings, quote, selectedQuoteExtras, selectedExtras, collectionDate, ddp, ioss, createOrderFromPayload, makePrepayPayment, handlePaymentSuccess, changeCheckoutStep, onBookingComplete, resetOrder, ] ); const onClose = useCallback(() => { resetValidation?.(); changeCheckoutStep({ id: 'idle' }); resetOrder?.(); }, [changeCheckoutStep, resetValidation, resetOrder]); switch (checkoutStep.id) { case 'dropshops': return ( ); case 'prepay-topup': return ( ( changeCheckoutStep({ id: 'confirming' }) } shouldCloseOnClickOutside={false} isDismissible={true} aria-describedby={__( 'Prepay Top-Up', 'parcel2go-shipping' )} > {children} )} /> ); case 'confirming': return ( {/* Shipping service */} {/* Shipping options */} {completeError && ( setCompleteError(null)} > {completeError} )} {/* Price breakdown and discount */} { resetValidation?.(); changeCheckoutStep({ id: 'idle' }); }} working={isCreatingOrder || updatingMeta} onPayWithPrePay={() => handlePayment('prepay', discountCode || null) } onPayWithProviders={() => handlePayment('card', discountCode || null) } isLinked={isLinked} setPrePayTopup={() => changeCheckoutStep({ id: 'prepay-topup' }) } /> ); case 'card-payment': return ( ); default: return null; } } export default memo(BookingManagerInner);