diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/ShippingEditForm.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingEditForm.tsx new file mode 100644 index 00000000..3fe9a83e --- /dev/null +++ b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingEditForm.tsx @@ -0,0 +1,395 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { type FormEvent, useId, useState, useTransition } from 'react'; + +type ShippingMethodCode = 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER'; + +type Props = { + orderId: string; + csrfToken: string; + initialShipping: { + methodCode: ShippingMethodCode; + cityRef: string; + cityLabel: string | null; + warehouseRef: string | null; + warehouseLabel: string | null; + addressLine1: string | null; + addressLine2: string | null; + recipientFullName: string; + recipientPhone: string; + recipientEmail: string | null; + recipientComment: string | null; + }; +}; + +function normalizeErrorCode(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 mapError(code: string, t: (key: string) => string): string { + switch (code) { + case 'NETWORK_ERROR': + return t('errors.network'); + case 'CSRF_MISSING': + case 'CSRF_REJECTED': + case 'ORIGIN_BLOCKED': + return t('errors.security'); + case 'INVALID_PAYLOAD': + case 'INVALID_SHIPPING_ADDRESS': + return t('errors.invalid'); + case 'SHIPPING_EDIT_NOT_ALLOWED': + case 'ORDER_NOT_SHIPPABLE': + case 'SHIPPING_NOT_REQUIRED': + case 'SHIPPING_PROVIDER_UNSUPPORTED': + return t('errors.notAllowed'); + case 'ADMIN_API_DISABLED': + return t('errors.adminDisabled'); + default: + return t('errors.generic'); + } +} + +function methodLabel( + value: ShippingMethodCode, + 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'); + } +} + +export function ShippingEditForm({ + orderId, + csrfToken, + initialShipping, +}: Props) { + const router = useRouter(); + const t = useTranslations('shop.orders.detail'); + const tEditor = useTranslations('shop.orders.detail.shippingEditor'); + const [isPending, startTransition] = useTransition(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [methodCode, setMethodCode] = useState( + initialShipping.methodCode + ); + const [cityRef, setCityRef] = useState(initialShipping.cityRef); + const [warehouseRef, setWarehouseRef] = useState( + initialShipping.warehouseRef ?? '' + ); + const [addressLine1, setAddressLine1] = useState( + initialShipping.addressLine1 ?? '' + ); + const [addressLine2, setAddressLine2] = useState( + initialShipping.addressLine2 ?? '' + ); + const [recipientFullName, setRecipientFullName] = useState( + initialShipping.recipientFullName + ); + const [recipientPhone, setRecipientPhone] = useState( + initialShipping.recipientPhone + ); + const [recipientEmail, setRecipientEmail] = useState( + initialShipping.recipientEmail ?? '' + ); + const [recipientComment, setRecipientComment] = useState( + initialShipping.recipientComment ?? '' + ); + const errorAlertId = `${useId()}-error`; + + const isWarehouseMethod = + methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER'; + const isCourierMethod = methodCode === 'NP_COURIER'; + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + if (isSubmitting || isPending) return; + + setError(null); + + const trimmedCityRef = cityRef.trim(); + const trimmedWarehouseRef = warehouseRef.trim(); + const trimmedAddressLine1 = addressLine1.trim(); + const trimmedAddressLine2 = addressLine2.trim(); + const trimmedRecipientFullName = recipientFullName.trim(); + const trimmedRecipientPhone = recipientPhone.trim(); + const trimmedRecipientEmail = recipientEmail.trim(); + const trimmedRecipientComment = recipientComment.trim(); + + const hasRequiredFields = + trimmedCityRef.length > 0 && + trimmedRecipientFullName.length > 0 && + trimmedRecipientPhone.length > 0 && + (!isWarehouseMethod || trimmedWarehouseRef.length > 0) && + (!isCourierMethod || trimmedAddressLine1.length > 0); + + if (!hasRequiredFields) { + setError(tEditor('errors.invalid')); + return; + } + + setIsSubmitting(true); + + let response: Response; + try { + response = await fetch(`/api/shop/admin/orders/${orderId}/shipping`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ + provider: 'nova_poshta', + methodCode, + selection: { + cityRef: trimmedCityRef, + ...(isWarehouseMethod + ? { warehouseRef: trimmedWarehouseRef } + : { + addressLine1: trimmedAddressLine1, + addressLine2: trimmedAddressLine2, + }), + }, + recipient: { + fullName: trimmedRecipientFullName, + phone: trimmedRecipientPhone, + ...(trimmedRecipientEmail.length > 0 + ? { email: trimmedRecipientEmail } + : {}), + ...(trimmedRecipientComment.length > 0 + ? { comment: trimmedRecipientComment } + : {}), + }, + }), + }); + } catch (requestError) { + setError(mapError(normalizeErrorCode(requestError), tEditor)); + setIsSubmitting(false); + return; + } + + let json: Record | null = null; + try { + json = (await response.json()) as Record; + } catch { + json = null; + } + + if (!response.ok) { + const code = + typeof json?.code === 'string' + ? json.code + : typeof json?.message === 'string' + ? json.message + : `HTTP_${response.status}`; + setError(mapError(code, tEditor)); + setIsSubmitting(false); + return; + } + + setIsSubmitting(false); + startTransition(() => { + router.refresh(); + }); + } + + return ( +
+
+ + +
+ +
+ + setCityRef(event.target.value)} + required + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> + {initialShipping.cityLabel ? ( +

+ {tEditor('currentCity', { city: initialShipping.cityLabel })} +

+ ) : null} +
+ + {isWarehouseMethod ? ( +
+ + setWarehouseRef(event.target.value)} + required + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> + {initialShipping.warehouseLabel ? ( +

+ {tEditor('currentPickupPoint', { + pickupPoint: initialShipping.warehouseLabel, + })} +

+ ) : null} +
+ ) : ( + <> +
+ + setAddressLine1(event.target.value)} + required={isCourierMethod} + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> +
+ +
+ + setAddressLine2(event.target.value)} + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> +
+ + )} + +
+ + setRecipientFullName(event.target.value)} + required + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> +
+ +
+ + setRecipientPhone(event.target.value)} + required + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> +
+ +
+ + setRecipientEmail(event.target.value)} + className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm" + /> +
+ +
+ +