From 31388f267d7ae2492b8cd93114f79781e94d25b6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Apr 2026 17:23:43 +0300 Subject: [PATCH] fix: make client delivery selection explicit --- src/components/client/DeliveryChoiceFlow.jsx | 43 ++++++++--- .../client/DeliveryChoiceFlow.test.jsx | 28 +++++++- src/components/client/DeliverySlotsPicker.jsx | 71 ++++++++++-------- .../client/DeliverySlotsPicker.test.jsx | 21 ++++-- .../client/deliveryDateFormatting.js | 72 +++++++++++++++++++ src/pages/ClientDeliveryPage.jsx | 67 ++++++++++++----- src/pages/ClientDeliveryPage.test.js | 59 +++++++++++++++ 7 files changed, 295 insertions(+), 66 deletions(-) create mode 100644 src/components/client/deliveryDateFormatting.js diff --git a/src/components/client/DeliveryChoiceFlow.jsx b/src/components/client/DeliveryChoiceFlow.jsx index b32af58..809483e 100644 --- a/src/components/client/DeliveryChoiceFlow.jsx +++ b/src/components/client/DeliveryChoiceFlow.jsx @@ -3,6 +3,7 @@ import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; import { Panel } from "../UI/Panel"; import { DeliveryStateNotice } from "./DeliveryStateNotice"; +import { formatDeliverySlotLabel } from "./deliveryDateFormatting"; const ACTIVE_STATES = new Set(["awaiting_choice", "opened", "reminder_sent"]); @@ -16,8 +17,6 @@ const STATE_LABELS = { agreed: "Доставка согласована", }; -const DEFAULT_SLOTS = ["Первая половина дня", "Вторая половина дня"]; - const splitOrderItem = (item) => { if (!item) { return null; @@ -50,20 +49,41 @@ const splitOrderItem = (item) => { export const DeliveryChoiceFlow = ({ invitation = {}, + selectedSlot = null, onConfirmChoice = () => {}, onRequestNewLink = () => {}, }) => { const state = invitation.state || "awaiting_choice"; const isActive = ACTIVE_STATES.has(state); - const slots = invitation.availableSlots?.length ? invitation.availableSlots : DEFAULT_SLOTS; const orderNumber = invitation.orderNumber || "—"; const customerName = invitation.customerName || "Клиент"; const orderItems = (invitation.orderItems || invitation.items || []) .map(splitOrderItem) .filter(Boolean); + const slotSummary = selectedSlot ? formatDeliverySlotLabel(selectedSlot) : ""; + + const selectionCard = ( +
+

Выбранный слот

+ {slotSummary ? ( +

+ Выбрано: {slotSummary} +

+ ) : ( +

+ Выберите дату и половину дня выше, затем нажмите «Сохранить». +

+ )} +
+ ); if (!isActive) { - return ; + return ( +
+ {slotSummary ? selectionCard : null} + +
+ ); } return ( @@ -96,15 +116,16 @@ export const DeliveryChoiceFlow = ({ ) : null} -
- {slots.map((slot) => ( - - ))} -
+ {selectionCard}
+ diff --git a/src/components/client/DeliveryChoiceFlow.test.jsx b/src/components/client/DeliveryChoiceFlow.test.jsx index 4719794..c9f2bf2 100644 --- a/src/components/client/DeliveryChoiceFlow.test.jsx +++ b/src/components/client/DeliveryChoiceFlow.test.jsx @@ -11,7 +11,10 @@ describe("DeliveryChoiceFlow", () => { state: "awaiting_choice", orderNumber: "CD-240031", customerName: "Мария Волкова", - availableSlots: ["Первая половина дня", "Вторая половина дня"], + }} + selectedSlot={{ + date: "2026-04-14", + time: "До обеда", }} onConfirmChoice={() => {}} onRequestNewLink={() => {}} @@ -19,11 +22,30 @@ describe("DeliveryChoiceFlow", () => { ); expect(markup).toContain("Выберите время доставки"); - expect(markup).toContain("Первая половина дня"); - expect(markup).toContain("Вторая половина дня"); + expect(markup).toContain("Выбрано"); + expect(markup).toContain("14.04.2026"); + expect(markup).toContain("До обеда"); + expect(markup).toContain("Сохранить"); expect(markup).toContain("Ожидает ответа клиента"); }); + it("renders a disabled save action when nothing is selected", () => { + const markup = renderToStaticMarkup( + {}} + onRequestNewLink={() => {}} + />, + ); + + expect(markup).toContain("Выберите дату и половину дня"); + expect(markup).toContain("disabled"); + }); + it("renders order items with quantities when they are provided", () => { const markup = renderToStaticMarkup( { - const date = new Date(`${dateStr}T12:00:00`); - return date.toLocaleDateString("ru-RU", { - day: "numeric", - month: "long", - weekday: "short", - }); -}; +import { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting"; const groupSlotsByDate = (slots) => { const groups = new Map(); @@ -25,7 +17,14 @@ const groupSlotsByDate = (slots) => { return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); }; -export const DeliverySlotsPicker = ({ slots, onSelectSlot, selectedSlotId }) => { +export { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting"; + +export const DeliverySlotsPicker = ({ + slots, + onSelectSlot, + selectedSlotId, + referenceDate = new Date(), +}) => { if (!slots || !slots.length) { return ( @@ -39,33 +38,45 @@ export const DeliverySlotsPicker = ({ slots, onSelectSlot, selectedSlotId }) => return (
-

Выберите дату и время доставки

+

Выберите день и половину дня доставки

- Нажмите на подходящий слот, чтобы подтвердить выбор. + Раскройте нужный день, выберите подходящую половину и затем сохраните выбор ниже.

{grouped.map(([date, dateSlots]) => ( - -

{formatSlotDate(date)}

-
- {dateSlots.map((slot) => { - const isSelected = selectedSlotId === slot.id; +
+ +
+
+

Доставка на день

+

{formatDeliverySlotGroupLabel(date, referenceDate)}

+
+ Раскрыть + Свернуть +
+
+
+
+ {dateSlots.map((slot) => { + const isSelected = selectedSlotId === slot.id; - return ( - - ); - })} + return ( + + ); + })} +
- +
))}
); -}; \ No newline at end of file +}; diff --git a/src/components/client/DeliverySlotsPicker.test.jsx b/src/components/client/DeliverySlotsPicker.test.jsx index 8979f77..78ecb85 100644 --- a/src/components/client/DeliverySlotsPicker.test.jsx +++ b/src/components/client/DeliverySlotsPicker.test.jsx @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { DeliverySlotsPicker } from "./DeliverySlotsPicker"; +import { + DeliverySlotsPicker, + formatDeliverySlotGroupLabel, +} from "./DeliverySlotsPicker"; const mockSlots = [ { date: "2026-04-14", time: "Первая половина дня", id: "slot-1" }, @@ -11,17 +14,27 @@ const mockSlots = [ ]; describe("DeliverySlotsPicker", () => { + it("formats tomorrow and the day after labels with dd.mm.yyyy dates", () => { + expect( + formatDeliverySlotGroupLabel("2026-04-14", new Date("2026-04-13T09:00:00Z")), + ).toBe("Завтра · 14.04.2026"); + expect( + formatDeliverySlotGroupLabel("2026-04-15", new Date("2026-04-13T09:00:00Z")), + ).toBe("Послезавтра · 15.04.2026"); + }); + it("renders slots grouped by date with half-day choices", () => { const markup = renderToStaticMarkup( {}} selectedSlotId={null} + referenceDate={new Date("2026-04-13T09:00:00Z")} />, ).toLowerCase(); - expect(markup).toContain("14 апреля"); - expect(markup).toContain("15 апреля"); + expect(markup).toContain("завтра · 14.04.2026"); + expect(markup).toContain("послезавтра · 15.04.2026"); expect(markup).toContain("первая половина дня"); expect(markup).toContain("вторая половина дня"); }); @@ -45,4 +58,4 @@ describe("DeliverySlotsPicker", () => { expect(markup).toContain("нет доступных слотов"); }); -}); \ No newline at end of file +}); diff --git a/src/components/client/deliveryDateFormatting.js b/src/components/client/deliveryDateFormatting.js new file mode 100644 index 0000000..889e2e5 --- /dev/null +++ b/src/components/client/deliveryDateFormatting.js @@ -0,0 +1,72 @@ +const DAY_IN_MS = 24 * 60 * 60 * 1000; + +const parseIsoDate = (dateStr) => { + const [year, month, day] = dateStr.split("-").map(Number); + if (!year || !month || !day) { + return null; + } + + return new Date(Date.UTC(year, month - 1, day)); +}; + +export const formatDeliveryDate = (dateStr) => { + const parsed = parseIsoDate(dateStr); + if (!parsed) { + return dateStr; + } + + const day = String(parsed.getUTCDate()).padStart(2, "0"); + const month = String(parsed.getUTCMonth() + 1).padStart(2, "0"); + const year = String(parsed.getUTCFullYear()); + + return `${day}.${month}.${year}`; +}; + +export const getDeliveryRelativeDayLabel = (dateStr, referenceDate = new Date()) => { + const target = parseIsoDate(dateStr); + if (!target) { + return ""; + } + + const reference = Date.UTC( + referenceDate.getUTCFullYear(), + referenceDate.getUTCMonth(), + referenceDate.getUTCDate(), + ); + const diff = Math.round((target.getTime() - reference) / DAY_IN_MS); + + if (diff === 1) { + return "Завтра"; + } + + if (diff === 2) { + return "Послезавтра"; + } + + return ""; +}; + +export const formatDeliverySlotGroupLabel = (dateStr, referenceDate = new Date()) => { + const formattedDate = formatDeliveryDate(dateStr); + const relativeLabel = getDeliveryRelativeDayLabel(dateStr, referenceDate); + + return relativeLabel ? `${relativeLabel} · ${formattedDate}` : formattedDate; +}; + +export const formatDeliverySlotLabel = ({ date, time } = {}) => { + const formattedDate = date ? formatDeliveryDate(date) : ""; + + if (!formattedDate && !time) { + return ""; + } + + if (!formattedDate) { + return time || ""; + } + + if (!time) { + return formattedDate; + } + + return `${formattedDate}, ${time}`; +}; diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 6c66b17..511d1e9 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -1,9 +1,10 @@ import React from "react"; -import { useParams, useSearchParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; import { Panel } from "../components/UI/Panel"; +import { formatDeliveryDate } from "../components/client/deliveryDateFormatting"; import { confirmDeliveryChoice, fetchDeliveryInvitation, @@ -43,7 +44,7 @@ export const groupSlotsFromInvitation = (invitation) => { return { id: `slot-${index}-${raw}`, - date: deliveryDate || parsedDate, + date: parsedDate || deliveryDate || "", time: timePart || deliveryTime || raw, }; }); @@ -58,14 +59,32 @@ export const buildDeliveryConfirmationPayload = ({ deliveryTime: slot?.time || invitation?.deliveryTime || undefined, }); +export const buildSelectedSlotFromInvitation = (invitation, slots = []) => { + if (!invitation?.deliveryDate) { + return null; + } + + const matchingSlot = slots.find( + (slot) => + slot.date === invitation.deliveryDate && + (!invitation.deliveryTime || slot.time === invitation.deliveryTime), + ); + + return matchingSlot || { + id: `slot-${invitation.deliveryDate}-${invitation.deliveryTime || "default"}`, + date: invitation.deliveryDate, + time: invitation.deliveryTime || "Половина дня", + }; +}; + export const ClientDeliveryPage = () => { const { token } = useParams(); - const [searchParams] = useSearchParams(); const [invitation, setInvitation] = React.useState(null); const [loading, setLoading] = React.useState(Boolean(token)); const [error, setError] = React.useState(""); const [actionMessage, setActionMessage] = React.useState(""); const [selectedSlotId, setSelectedSlotId] = React.useState(null); + const [selectedSlot, setSelectedSlot] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -110,44 +129,57 @@ export const ClientDeliveryPage = () => { ); const invitationState = invitation?.state || "awaiting_choice"; + const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); - const handleConfirmChoice = React.useCallback( - async ({ deliveryDate, deliveryTime }) => { + const invitationSelectedSlot = React.useMemo( + () => (isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots)), + [invitation, slots, isActiveState], + ); + + const effectiveSelectedSlot = selectedSlot || invitationSelectedSlot; + + const handleSaveChoice = React.useCallback( + async () => { if (!token) { return; } + if (!effectiveSelectedSlot) { + setError("Сначала выберите дату и половину дня."); + return; + } + setActionMessage("Сохраняем выбор..."); + setError(""); try { await confirmDeliveryChoice({ token, - deliveryTime, - deliveryDate, + deliveryTime: effectiveSelectedSlot.time, + deliveryDate: effectiveSelectedSlot.date, }); const loadedInvitation = await fetchDeliveryInvitation(token); setInvitation(loadedInvitation); + setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot); setActionMessage("Выбор сохранен, спасибо."); } catch (confirmError) { setActionMessage(""); setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор"); } }, - [token, invitation], + [effectiveSelectedSlot, token], ); const handleSlotSelect = React.useCallback( (slot) => { setSelectedSlotId(slot.id); - handleConfirmChoice( - buildDeliveryConfirmationPayload({ - slot, - invitation, - searchDate: searchParams.get("date"), - }), + setSelectedSlot(slot); + setActionMessage( + `Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`, ); + setError(""); }, - [handleConfirmChoice, invitation, searchParams], + [], ); const handleRequestNewLink = React.useCallback(() => { @@ -182,8 +214,6 @@ export const ClientDeliveryPage = () => { ); } - const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); - return (
@@ -208,7 +238,8 @@ export const ClientDeliveryPage = () => { {isActiveState ? ( ) : ( diff --git a/src/pages/ClientDeliveryPage.test.js b/src/pages/ClientDeliveryPage.test.js index e3d1e53..808f4eb 100644 --- a/src/pages/ClientDeliveryPage.test.js +++ b/src/pages/ClientDeliveryPage.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildDeliveryConfirmationPayload, + buildSelectedSlotFromInvitation, groupSlotsFromInvitation, } from "./ClientDeliveryPage"; @@ -45,4 +46,62 @@ describe("ClientDeliveryPage helpers", () => { deliveryTime: "После обеда", }); }); + + it("keeps the explicit slot dates when invitation already has a delivery date", () => { + expect( + groupSlotsFromInvitation({ + deliveryDate: "2026-04-14", + deliveryTime: "До обеда", + availableSlots: [ + "2026-04-14, До обеда", + "2026-04-14, После обеда", + "2026-04-15, До обеда", + "2026-04-15, После обеда", + ], + }), + ).toEqual([ + { + id: "slot-0-2026-04-14, До обеда", + date: "2026-04-14", + time: "До обеда", + }, + { + id: "slot-1-2026-04-14, После обеда", + date: "2026-04-14", + time: "После обеда", + }, + { + id: "slot-2-2026-04-15, До обеда", + date: "2026-04-15", + time: "До обеда", + }, + { + id: "slot-3-2026-04-15, После обеда", + date: "2026-04-15", + time: "После обеда", + }, + ]); + }); + + it("builds a selected slot from invitation data", () => { + expect( + buildSelectedSlotFromInvitation( + { + deliveryDate: "2026-04-15", + deliveryTime: "После обеда", + }, + [ + { + id: "slot-1", + date: "2026-04-15", + time: "После обеда", + }, + ], + ), + ).toEqual({ + id: "slot-1", + date: "2026-04-15", + time: "После обеда", + }); + }); });