Skip to content

Commit 2049f86

Browse files
(SP: 3) [SHOP] complete cleanup across merchandising, admin operations, shipment visibility, and public runtime safety (#439)
1 parent 6f74dda commit 2049f86

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+4401
-499
lines changed
Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { useTranslations } from 'next-intl';
5+
import { type FormEvent, useId, useState, useTransition } from 'react';
6+
7+
type ShippingMethodCode = 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER';
8+
9+
type Props = {
10+
orderId: string;
11+
csrfToken: string;
12+
initialShipping: {
13+
methodCode: ShippingMethodCode;
14+
cityRef: string;
15+
cityLabel: string | null;
16+
warehouseRef: string | null;
17+
warehouseLabel: string | null;
18+
addressLine1: string | null;
19+
addressLine2: string | null;
20+
recipientFullName: string;
21+
recipientPhone: string;
22+
recipientEmail: string | null;
23+
recipientComment: string | null;
24+
};
25+
};
26+
27+
function normalizeErrorCode(error: unknown): string {
28+
if (error instanceof TypeError) return 'NETWORK_ERROR';
29+
if (error instanceof Error && error.message.trim().length > 0) {
30+
return error.message;
31+
}
32+
return 'NETWORK_ERROR';
33+
}
34+
35+
function mapError(code: string, t: (key: string) => string): string {
36+
switch (code) {
37+
case 'NETWORK_ERROR':
38+
return t('errors.network');
39+
case 'CSRF_MISSING':
40+
case 'CSRF_REJECTED':
41+
case 'ORIGIN_BLOCKED':
42+
return t('errors.security');
43+
case 'INVALID_PAYLOAD':
44+
case 'INVALID_SHIPPING_ADDRESS':
45+
return t('errors.invalid');
46+
case 'SHIPPING_EDIT_NOT_ALLOWED':
47+
case 'ORDER_NOT_SHIPPABLE':
48+
case 'SHIPPING_NOT_REQUIRED':
49+
case 'SHIPPING_PROVIDER_UNSUPPORTED':
50+
return t('errors.notAllowed');
51+
case 'ADMIN_API_DISABLED':
52+
return t('errors.adminDisabled');
53+
default:
54+
return t('errors.generic');
55+
}
56+
}
57+
58+
function methodLabel(
59+
value: ShippingMethodCode,
60+
t: (key: string) => string
61+
): string {
62+
switch (value) {
63+
case 'NP_WAREHOUSE':
64+
return t('shippingMethods.novaPoshtaWarehouse');
65+
case 'NP_LOCKER':
66+
return t('shippingMethods.novaPoshtaLocker');
67+
case 'NP_COURIER':
68+
return t('shippingMethods.novaPoshtaCourier');
69+
}
70+
}
71+
72+
export function ShippingEditForm({
73+
orderId,
74+
csrfToken,
75+
initialShipping,
76+
}: Props) {
77+
const router = useRouter();
78+
const t = useTranslations('shop.orders.detail');
79+
const tEditor = useTranslations('shop.orders.detail.shippingEditor');
80+
const [isPending, startTransition] = useTransition();
81+
const [isSubmitting, setIsSubmitting] = useState(false);
82+
const [error, setError] = useState<string | null>(null);
83+
const [methodCode, setMethodCode] = useState<ShippingMethodCode>(
84+
initialShipping.methodCode
85+
);
86+
const [cityRef, setCityRef] = useState(initialShipping.cityRef);
87+
const [warehouseRef, setWarehouseRef] = useState(
88+
initialShipping.warehouseRef ?? ''
89+
);
90+
const [addressLine1, setAddressLine1] = useState(
91+
initialShipping.addressLine1 ?? ''
92+
);
93+
const [addressLine2, setAddressLine2] = useState(
94+
initialShipping.addressLine2 ?? ''
95+
);
96+
const [recipientFullName, setRecipientFullName] = useState(
97+
initialShipping.recipientFullName
98+
);
99+
const [recipientPhone, setRecipientPhone] = useState(
100+
initialShipping.recipientPhone
101+
);
102+
const [recipientEmail, setRecipientEmail] = useState(
103+
initialShipping.recipientEmail ?? ''
104+
);
105+
const [recipientComment, setRecipientComment] = useState(
106+
initialShipping.recipientComment ?? ''
107+
);
108+
const errorAlertId = `${useId()}-error`;
109+
110+
const isWarehouseMethod =
111+
methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER';
112+
const isCourierMethod = methodCode === 'NP_COURIER';
113+
114+
async function onSubmit(event: FormEvent<HTMLFormElement>) {
115+
event.preventDefault();
116+
if (isSubmitting || isPending) return;
117+
118+
setError(null);
119+
120+
const trimmedCityRef = cityRef.trim();
121+
const trimmedWarehouseRef = warehouseRef.trim();
122+
const trimmedAddressLine1 = addressLine1.trim();
123+
const trimmedAddressLine2 = addressLine2.trim();
124+
const trimmedRecipientFullName = recipientFullName.trim();
125+
const trimmedRecipientPhone = recipientPhone.trim();
126+
const trimmedRecipientEmail = recipientEmail.trim();
127+
const trimmedRecipientComment = recipientComment.trim();
128+
129+
const hasRequiredFields =
130+
trimmedCityRef.length > 0 &&
131+
trimmedRecipientFullName.length > 0 &&
132+
trimmedRecipientPhone.length > 0 &&
133+
(!isWarehouseMethod || trimmedWarehouseRef.length > 0) &&
134+
(!isCourierMethod || trimmedAddressLine1.length > 0);
135+
136+
if (!hasRequiredFields) {
137+
setError(tEditor('errors.invalid'));
138+
return;
139+
}
140+
141+
setIsSubmitting(true);
142+
143+
let response: Response;
144+
try {
145+
response = await fetch(`/api/shop/admin/orders/${orderId}/shipping`, {
146+
method: 'PATCH',
147+
credentials: 'same-origin',
148+
headers: {
149+
'Content-Type': 'application/json',
150+
'x-csrf-token': csrfToken,
151+
},
152+
body: JSON.stringify({
153+
provider: 'nova_poshta',
154+
methodCode,
155+
selection: {
156+
cityRef: trimmedCityRef,
157+
...(isWarehouseMethod
158+
? { warehouseRef: trimmedWarehouseRef }
159+
: {
160+
addressLine1: trimmedAddressLine1,
161+
addressLine2: trimmedAddressLine2,
162+
}),
163+
},
164+
recipient: {
165+
fullName: trimmedRecipientFullName,
166+
phone: trimmedRecipientPhone,
167+
...(trimmedRecipientEmail.length > 0
168+
? { email: trimmedRecipientEmail }
169+
: {}),
170+
...(trimmedRecipientComment.length > 0
171+
? { comment: trimmedRecipientComment }
172+
: {}),
173+
},
174+
}),
175+
});
176+
} catch (requestError) {
177+
setError(mapError(normalizeErrorCode(requestError), tEditor));
178+
setIsSubmitting(false);
179+
return;
180+
}
181+
182+
let json: Record<string, unknown> | null = null;
183+
try {
184+
json = (await response.json()) as Record<string, unknown>;
185+
} catch {
186+
json = null;
187+
}
188+
189+
if (!response.ok) {
190+
const code =
191+
typeof json?.code === 'string'
192+
? json.code
193+
: typeof json?.message === 'string'
194+
? json.message
195+
: `HTTP_${response.status}`;
196+
setError(mapError(code, tEditor));
197+
setIsSubmitting(false);
198+
return;
199+
}
200+
201+
setIsSubmitting(false);
202+
startTransition(() => {
203+
router.refresh();
204+
});
205+
}
206+
207+
return (
208+
<form className="grid gap-3" onSubmit={onSubmit}>
209+
<div>
210+
<label
211+
className="text-muted-foreground mb-1 block text-xs"
212+
htmlFor="shipping-method-code"
213+
>
214+
{t('shippingMethod')}
215+
</label>
216+
<select
217+
id="shipping-method-code"
218+
value={methodCode}
219+
onChange={event =>
220+
setMethodCode(event.target.value as ShippingMethodCode)
221+
}
222+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
223+
>
224+
<option value="NP_WAREHOUSE">{methodLabel('NP_WAREHOUSE', t)}</option>
225+
<option value="NP_LOCKER">{methodLabel('NP_LOCKER', t)}</option>
226+
<option value="NP_COURIER">{methodLabel('NP_COURIER', t)}</option>
227+
</select>
228+
</div>
229+
230+
<div>
231+
<label
232+
className="text-muted-foreground mb-1 block text-xs"
233+
htmlFor="shipping-city-ref"
234+
>
235+
{tEditor('cityRef')}
236+
</label>
237+
<input
238+
id="shipping-city-ref"
239+
value={cityRef}
240+
onChange={event => setCityRef(event.target.value)}
241+
required
242+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
243+
/>
244+
{initialShipping.cityLabel ? (
245+
<p className="text-muted-foreground mt-1 text-[11px]">
246+
{tEditor('currentCity', { city: initialShipping.cityLabel })}
247+
</p>
248+
) : null}
249+
</div>
250+
251+
{isWarehouseMethod ? (
252+
<div>
253+
<label
254+
className="text-muted-foreground mb-1 block text-xs"
255+
htmlFor="shipping-warehouse-ref"
256+
>
257+
{tEditor('pickupPointRef')}
258+
</label>
259+
<input
260+
id="shipping-warehouse-ref"
261+
value={warehouseRef}
262+
onChange={event => setWarehouseRef(event.target.value)}
263+
required
264+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
265+
/>
266+
{initialShipping.warehouseLabel ? (
267+
<p className="text-muted-foreground mt-1 text-[11px]">
268+
{tEditor('currentPickupPoint', {
269+
pickupPoint: initialShipping.warehouseLabel,
270+
})}
271+
</p>
272+
) : null}
273+
</div>
274+
) : (
275+
<>
276+
<div>
277+
<label
278+
className="text-muted-foreground mb-1 block text-xs"
279+
htmlFor="shipping-address-line-1"
280+
>
281+
{tEditor('addressLine1')}
282+
</label>
283+
<input
284+
id="shipping-address-line-1"
285+
value={addressLine1}
286+
onChange={event => setAddressLine1(event.target.value)}
287+
required={isCourierMethod}
288+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
289+
/>
290+
</div>
291+
292+
<div>
293+
<label
294+
className="text-muted-foreground mb-1 block text-xs"
295+
htmlFor="shipping-address-line-2"
296+
>
297+
{tEditor('addressLine2')}
298+
</label>
299+
<input
300+
id="shipping-address-line-2"
301+
value={addressLine2}
302+
onChange={event => setAddressLine2(event.target.value)}
303+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
304+
/>
305+
</div>
306+
</>
307+
)}
308+
309+
<div>
310+
<label
311+
className="text-muted-foreground mb-1 block text-xs"
312+
htmlFor="shipping-recipient-full-name"
313+
>
314+
{t('recipientName')}
315+
</label>
316+
<input
317+
id="shipping-recipient-full-name"
318+
value={recipientFullName}
319+
onChange={event => setRecipientFullName(event.target.value)}
320+
required
321+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
322+
/>
323+
</div>
324+
325+
<div>
326+
<label
327+
className="text-muted-foreground mb-1 block text-xs"
328+
htmlFor="shipping-recipient-phone"
329+
>
330+
{t('recipientPhone')}
331+
</label>
332+
<input
333+
id="shipping-recipient-phone"
334+
value={recipientPhone}
335+
onChange={event => setRecipientPhone(event.target.value)}
336+
required
337+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
338+
/>
339+
</div>
340+
341+
<div>
342+
<label
343+
className="text-muted-foreground mb-1 block text-xs"
344+
htmlFor="shipping-recipient-email"
345+
>
346+
{t('recipientEmail')}
347+
</label>
348+
<input
349+
id="shipping-recipient-email"
350+
value={recipientEmail}
351+
onChange={event => setRecipientEmail(event.target.value)}
352+
className="border-border bg-background text-foreground w-full rounded-lg border px-3 py-2 text-sm"
353+
/>
354+
</div>
355+
356+
<div>
357+
<label
358+
className="text-muted-foreground mb-1 block text-xs"
359+
htmlFor="shipping-recipient-comment"
360+
>
361+
{t('comment')}
362+
</label>
363+
<textarea
364+
id="shipping-recipient-comment"
365+
value={recipientComment}
366+
onChange={event => setRecipientComment(event.target.value)}
367+
className="border-border bg-background text-foreground min-h-24 w-full rounded-lg border px-3 py-2 text-sm"
368+
/>
369+
</div>
370+
371+
<div className="flex items-center justify-between gap-3">
372+
<p className="text-muted-foreground text-xs">{tEditor('subtitle')}</p>
373+
<button
374+
type="submit"
375+
disabled={isSubmitting || isPending}
376+
aria-busy={isSubmitting || isPending}
377+
aria-describedby={error ? errorAlertId : undefined}
378+
className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm font-medium text-emerald-700 transition-colors hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-50 dark:text-emerald-100"
379+
>
380+
{isSubmitting || isPending ? tEditor('saving') : tEditor('save')}
381+
</button>
382+
</div>
383+
384+
{error ? (
385+
<p
386+
id={errorAlertId}
387+
role="alert"
388+
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-100"
389+
>
390+
{error}
391+
</p>
392+
) : null}
393+
</form>
394+
);
395+
}

0 commit comments

Comments
 (0)