fix: make client delivery selection explicit

This commit is contained in:
Codex 2026-04-16 17:23:43 +03:00
parent 3b4b6648ff
commit 31388f267d
7 changed files with 295 additions and 66 deletions

View File

@ -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 = (
<div className="space-y-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Выбранный слот</p>
{slotSummary ? (
<p className="text-sm leading-6">
<span className="font-medium">Выбрано:</span> {slotSummary}
</p>
) : (
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Выберите дату и половину дня выше, затем нажмите «Сохранить».
</p>
)}
</div>
);
if (!isActive) {
return <DeliveryStateNotice state={state} />;
return (
<div className="space-y-4">
{slotSummary ? selectionCard : null}
<DeliveryStateNotice state={state} />
</div>
);
}
return (
@ -96,15 +116,16 @@ export const DeliveryChoiceFlow = ({
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{slots.map((slot) => (
<Button key={slot} className="w-full" onClick={() => onConfirmChoice(slot)}>
{slot}
</Button>
))}
</div>
{selectionCard}
<div className="flex flex-col gap-3 sm:flex-row">
<Button
className="w-full sm:w-auto"
disabled={!slotSummary}
onClick={() => onConfirmChoice(selectedSlot)}
>
Сохранить
</Button>
<Button variant="secondary" className="w-full sm:w-auto" onClick={onRequestNewLink}>
Запросить новую ссылку
</Button>

View File

@ -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(
<DeliveryChoiceFlow
invitation={{
state: "awaiting_choice",
orderNumber: "CD-240031",
customerName: "Мария Волкова",
}}
onConfirmChoice={() => {}}
onRequestNewLink={() => {}}
/>,
);
expect(markup).toContain("Выберите дату и половину дня");
expect(markup).toContain("disabled");
});
it("renders order items with quantities when they are provided", () => {
const markup = renderToStaticMarkup(
<DeliveryChoiceFlow

View File

@ -1,15 +1,7 @@
import React from "react";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
const formatSlotDate = (dateStr) => {
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 (
<Panel className="p-5 sm:p-6">
@ -39,32 +38,44 @@ export const DeliverySlotsPicker = ({ slots, onSelectSlot, selectedSlotId }) =>
return (
<div className="space-y-4">
<Panel className="p-5 sm:p-6">
<h3 className="text-lg font-semibold">Выберите дату и время доставки</h3>
<h3 className="text-lg font-semibold">Выберите день и половину дня доставки</h3>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Нажмите на подходящий слот, чтобы подтвердить выбор.
Раскройте нужный день, выберите подходящую половину и затем сохраните выбор ниже.
</p>
</Panel>
{grouped.map(([date, dateSlots]) => (
<Panel key={date} className="space-y-3 p-5 sm:p-6">
<h4 className="font-medium capitalize">{formatSlotDate(date)}</h4>
<div className="grid gap-3 sm:grid-cols-2">
{dateSlots.map((slot) => {
const isSelected = selectedSlotId === slot.id;
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
<summary className="cursor-pointer list-none p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Доставка на день</p>
<h4 className="font-medium">{formatDeliverySlotGroupLabel(date, referenceDate)}</h4>
</div>
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
</div>
</summary>
<div className="px-5 pb-5 sm:px-6 sm:pb-6">
<div className="grid gap-3 sm:grid-cols-2">
{dateSlots.map((slot) => {
const isSelected = selectedSlotId === slot.id;
return (
<Button
key={slot.id}
variant={isSelected ? "primary" : "secondary"}
onClick={() => onSelectSlot(slot)}
>
{slot.time}
{isSelected ? " \u2014 Выбрано" : ""}
</Button>
);
})}
return (
<Button
key={slot.id}
variant={isSelected ? "primary" : "secondary"}
aria-pressed={isSelected}
onClick={() => onSelectSlot(slot)}
>
{slot.time}
{isSelected ? " — Выбрано" : ""}
</Button>
);
})}
</div>
</div>
</Panel>
</details>
))}
</div>
);

View File

@ -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(
<DeliverySlotsPicker
slots={mockSlots}
onSelectSlot={() => {}}
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("вторая половина дня");
});

View File

@ -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}`;
};

View File

@ -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 (
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
@ -208,7 +238,8 @@ export const ClientDeliveryPage = () => {
{isActiveState ? (
<DeliveryChoiceFlow
invitation={invitation}
onConfirmChoice={handleConfirmChoice}
selectedSlot={effectiveSelectedSlot}
onConfirmChoice={handleSaveChoice}
onRequestNewLink={handleRequestNewLink}
/>
) : (

View File

@ -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: "После обеда",
});
});
});