fix: make client delivery selection explicit
This commit is contained in:
parent
3b4b6648ff
commit
31388f267d
|
|
@ -3,6 +3,7 @@ import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { DeliveryStateNotice } from "./DeliveryStateNotice";
|
import { DeliveryStateNotice } from "./DeliveryStateNotice";
|
||||||
|
import { formatDeliverySlotLabel } from "./deliveryDateFormatting";
|
||||||
|
|
||||||
const ACTIVE_STATES = new Set(["awaiting_choice", "opened", "reminder_sent"]);
|
const ACTIVE_STATES = new Set(["awaiting_choice", "opened", "reminder_sent"]);
|
||||||
|
|
||||||
|
|
@ -16,8 +17,6 @@ const STATE_LABELS = {
|
||||||
agreed: "Доставка согласована",
|
agreed: "Доставка согласована",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SLOTS = ["Первая половина дня", "Вторая половина дня"];
|
|
||||||
|
|
||||||
const splitOrderItem = (item) => {
|
const splitOrderItem = (item) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -50,20 +49,41 @@ const splitOrderItem = (item) => {
|
||||||
|
|
||||||
export const DeliveryChoiceFlow = ({
|
export const DeliveryChoiceFlow = ({
|
||||||
invitation = {},
|
invitation = {},
|
||||||
|
selectedSlot = null,
|
||||||
onConfirmChoice = () => {},
|
onConfirmChoice = () => {},
|
||||||
onRequestNewLink = () => {},
|
onRequestNewLink = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const state = invitation.state || "awaiting_choice";
|
const state = invitation.state || "awaiting_choice";
|
||||||
const isActive = ACTIVE_STATES.has(state);
|
const isActive = ACTIVE_STATES.has(state);
|
||||||
const slots = invitation.availableSlots?.length ? invitation.availableSlots : DEFAULT_SLOTS;
|
|
||||||
const orderNumber = invitation.orderNumber || "—";
|
const orderNumber = invitation.orderNumber || "—";
|
||||||
const customerName = invitation.customerName || "Клиент";
|
const customerName = invitation.customerName || "Клиент";
|
||||||
const orderItems = (invitation.orderItems || invitation.items || [])
|
const orderItems = (invitation.orderItems || invitation.items || [])
|
||||||
.map(splitOrderItem)
|
.map(splitOrderItem)
|
||||||
.filter(Boolean);
|
.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) {
|
if (!isActive) {
|
||||||
return <DeliveryStateNotice state={state} />;
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{slotSummary ? selectionCard : null}
|
||||||
|
<DeliveryStateNotice state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -96,15 +116,16 @@ export const DeliveryChoiceFlow = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{selectionCard}
|
||||||
{slots.map((slot) => (
|
|
||||||
<Button key={slot} className="w-full" onClick={() => onConfirmChoice(slot)}>
|
|
||||||
{slot}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<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 variant="secondary" className="w-full sm:w-auto" onClick={onRequestNewLink}>
|
||||||
Запросить новую ссылку
|
Запросить новую ссылку
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
state: "awaiting_choice",
|
state: "awaiting_choice",
|
||||||
orderNumber: "CD-240031",
|
orderNumber: "CD-240031",
|
||||||
customerName: "Мария Волкова",
|
customerName: "Мария Волкова",
|
||||||
availableSlots: ["Первая половина дня", "Вторая половина дня"],
|
}}
|
||||||
|
selectedSlot={{
|
||||||
|
date: "2026-04-14",
|
||||||
|
time: "До обеда",
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
onRequestNewLink={() => {}}
|
||||||
|
|
@ -19,11 +22,30 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Выберите время доставки");
|
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("Ожидает ответа клиента");
|
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", () => {
|
it("renders order items with quantities when they are provided", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<DeliveryChoiceFlow
|
<DeliveryChoiceFlow
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
|
||||||
const formatSlotDate = (dateStr) => {
|
|
||||||
const date = new Date(`${dateStr}T12:00:00`);
|
|
||||||
return date.toLocaleDateString("ru-RU", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
weekday: "short",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupSlotsByDate = (slots) => {
|
const groupSlotsByDate = (slots) => {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|
@ -25,7 +17,14 @@ const groupSlotsByDate = (slots) => {
|
||||||
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b));
|
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) {
|
if (!slots || !slots.length) {
|
||||||
return (
|
return (
|
||||||
<Panel className="p-5 sm:p-6">
|
<Panel className="p-5 sm:p-6">
|
||||||
|
|
@ -39,15 +38,25 @@ export const DeliverySlotsPicker = ({ slots, onSelectSlot, selectedSlotId }) =>
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Panel className="p-5 sm:p-6">
|
<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 className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
Нажмите на подходящий слот, чтобы подтвердить выбор.
|
Раскройте нужный день, выберите подходящую половину и затем сохраните выбор ниже.
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{grouped.map(([date, dateSlots]) => (
|
{grouped.map(([date, dateSlots]) => (
|
||||||
<Panel key={date} className="space-y-3 p-5 sm:p-6">
|
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
|
||||||
<h4 className="font-medium capitalize">{formatSlotDate(date)}</h4>
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{dateSlots.map((slot) => {
|
{dateSlots.map((slot) => {
|
||||||
const isSelected = selectedSlotId === slot.id;
|
const isSelected = selectedSlotId === slot.id;
|
||||||
|
|
@ -56,15 +65,17 @@ export const DeliverySlotsPicker = ({ slots, onSelectSlot, selectedSlotId }) =>
|
||||||
<Button
|
<Button
|
||||||
key={slot.id}
|
key={slot.id}
|
||||||
variant={isSelected ? "primary" : "secondary"}
|
variant={isSelected ? "primary" : "secondary"}
|
||||||
|
aria-pressed={isSelected}
|
||||||
onClick={() => onSelectSlot(slot)}
|
onClick={() => onSelectSlot(slot)}
|
||||||
>
|
>
|
||||||
{slot.time}
|
{slot.time}
|
||||||
{isSelected ? " \u2014 Выбрано" : ""}
|
{isSelected ? " — Выбрано" : ""}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</div>
|
||||||
|
</details>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { DeliverySlotsPicker } from "./DeliverySlotsPicker";
|
import {
|
||||||
|
DeliverySlotsPicker,
|
||||||
|
formatDeliverySlotGroupLabel,
|
||||||
|
} from "./DeliverySlotsPicker";
|
||||||
|
|
||||||
const mockSlots = [
|
const mockSlots = [
|
||||||
{ date: "2026-04-14", time: "Первая половина дня", id: "slot-1" },
|
{ date: "2026-04-14", time: "Первая половина дня", id: "slot-1" },
|
||||||
|
|
@ -11,17 +14,27 @@ const mockSlots = [
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("DeliverySlotsPicker", () => {
|
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", () => {
|
it("renders slots grouped by date with half-day choices", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<DeliverySlotsPicker
|
<DeliverySlotsPicker
|
||||||
slots={mockSlots}
|
slots={mockSlots}
|
||||||
onSelectSlot={() => {}}
|
onSelectSlot={() => {}}
|
||||||
selectedSlotId={null}
|
selectedSlotId={null}
|
||||||
|
referenceDate={new Date("2026-04-13T09:00:00Z")}
|
||||||
/>,
|
/>,
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
|
|
||||||
expect(markup).toContain("14 апреля");
|
expect(markup).toContain("завтра · 14.04.2026");
|
||||||
expect(markup).toContain("15 апреля");
|
expect(markup).toContain("послезавтра · 15.04.2026");
|
||||||
expect(markup).toContain("первая половина дня");
|
expect(markup).toContain("первая половина дня");
|
||||||
expect(markup).toContain("вторая половина дня");
|
expect(markup).toContain("вторая половина дня");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
};
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
||||||
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
||||||
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
||||||
import {
|
import {
|
||||||
confirmDeliveryChoice,
|
confirmDeliveryChoice,
|
||||||
fetchDeliveryInvitation,
|
fetchDeliveryInvitation,
|
||||||
|
|
@ -43,7 +44,7 @@ export const groupSlotsFromInvitation = (invitation) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `slot-${index}-${raw}`,
|
id: `slot-${index}-${raw}`,
|
||||||
date: deliveryDate || parsedDate,
|
date: parsedDate || deliveryDate || "",
|
||||||
time: timePart || deliveryTime || raw,
|
time: timePart || deliveryTime || raw,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -58,14 +59,32 @@ export const buildDeliveryConfirmationPayload = ({
|
||||||
deliveryTime: slot?.time || invitation?.deliveryTime || undefined,
|
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 = () => {
|
export const ClientDeliveryPage = () => {
|
||||||
const { token } = useParams();
|
const { token } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [invitation, setInvitation] = React.useState(null);
|
const [invitation, setInvitation] = React.useState(null);
|
||||||
const [loading, setLoading] = React.useState(Boolean(token));
|
const [loading, setLoading] = React.useState(Boolean(token));
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
const [actionMessage, setActionMessage] = React.useState("");
|
const [actionMessage, setActionMessage] = React.useState("");
|
||||||
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
|
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
|
||||||
|
const [selectedSlot, setSelectedSlot] = React.useState(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -110,44 +129,57 @@ export const ClientDeliveryPage = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationState = invitation?.state || "awaiting_choice";
|
const invitationState = invitation?.state || "awaiting_choice";
|
||||||
|
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
||||||
|
|
||||||
const handleConfirmChoice = React.useCallback(
|
const invitationSelectedSlot = React.useMemo(
|
||||||
async ({ deliveryDate, deliveryTime }) => {
|
() => (isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots)),
|
||||||
|
[invitation, slots, isActiveState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveSelectedSlot = selectedSlot || invitationSelectedSlot;
|
||||||
|
|
||||||
|
const handleSaveChoice = React.useCallback(
|
||||||
|
async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!effectiveSelectedSlot) {
|
||||||
|
setError("Сначала выберите дату и половину дня.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setActionMessage("Сохраняем выбор...");
|
setActionMessage("Сохраняем выбор...");
|
||||||
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await confirmDeliveryChoice({
|
await confirmDeliveryChoice({
|
||||||
token,
|
token,
|
||||||
deliveryTime,
|
deliveryTime: effectiveSelectedSlot.time,
|
||||||
deliveryDate,
|
deliveryDate: effectiveSelectedSlot.date,
|
||||||
});
|
});
|
||||||
const loadedInvitation = await fetchDeliveryInvitation(token);
|
const loadedInvitation = await fetchDeliveryInvitation(token);
|
||||||
setInvitation(loadedInvitation);
|
setInvitation(loadedInvitation);
|
||||||
|
setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot);
|
||||||
setActionMessage("Выбор сохранен, спасибо.");
|
setActionMessage("Выбор сохранен, спасибо.");
|
||||||
} catch (confirmError) {
|
} catch (confirmError) {
|
||||||
setActionMessage("");
|
setActionMessage("");
|
||||||
setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор");
|
setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[token, invitation],
|
[effectiveSelectedSlot, token],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSlotSelect = React.useCallback(
|
const handleSlotSelect = React.useCallback(
|
||||||
(slot) => {
|
(slot) => {
|
||||||
setSelectedSlotId(slot.id);
|
setSelectedSlotId(slot.id);
|
||||||
handleConfirmChoice(
|
setSelectedSlot(slot);
|
||||||
buildDeliveryConfirmationPayload({
|
setActionMessage(
|
||||||
slot,
|
`Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`,
|
||||||
invitation,
|
|
||||||
searchDate: searchParams.get("date"),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
setError("");
|
||||||
},
|
},
|
||||||
[handleConfirmChoice, invitation, searchParams],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRequestNewLink = React.useCallback(() => {
|
const handleRequestNewLink = React.useCallback(() => {
|
||||||
|
|
@ -182,8 +214,6 @@ export const ClientDeliveryPage = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
<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">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||||
|
|
@ -208,7 +238,8 @@ export const ClientDeliveryPage = () => {
|
||||||
{isActiveState ? (
|
{isActiveState ? (
|
||||||
<DeliveryChoiceFlow
|
<DeliveryChoiceFlow
|
||||||
invitation={invitation}
|
invitation={invitation}
|
||||||
onConfirmChoice={handleConfirmChoice}
|
selectedSlot={effectiveSelectedSlot}
|
||||||
|
onConfirmChoice={handleSaveChoice}
|
||||||
onRequestNewLink={handleRequestNewLink}
|
onRequestNewLink={handleRequestNewLink}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildDeliveryConfirmationPayload,
|
buildDeliveryConfirmationPayload,
|
||||||
|
buildSelectedSlotFromInvitation,
|
||||||
groupSlotsFromInvitation,
|
groupSlotsFromInvitation,
|
||||||
} from "./ClientDeliveryPage";
|
} from "./ClientDeliveryPage";
|
||||||
|
|
||||||
|
|
@ -45,4 +46,62 @@ describe("ClientDeliveryPage helpers", () => {
|
||||||
deliveryTime: "После обеда",
|
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: "После обеда",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue