fix(delivery): tighten public flow

Filter client delivery slots to tomorrow and the day after,\nkeep invalid delivery times out of the UI, and show a clearer\nerror state when saving fails.
This commit is contained in:
Codex 2026-05-13 16:17:33 +03:00
parent 633973142d
commit 7e399f2517
4 changed files with 144 additions and 14 deletions

View File

@ -10,7 +10,47 @@ import {
fetchDeliveryInvitation, fetchDeliveryInvitation,
} from "../services/deliveryInvitationApi"; } from "../services/deliveryInvitationApi";
export const groupSlotsFromInvitation = (invitation) => { const DELIVERY_TIMEZONE = "Europe/Simferopol";
const getBusinessTodayKey = (referenceDate = new Date()) => {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: DELIVERY_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(referenceDate);
const year = parts.find((part) => part.type === "year")?.value || "";
const month = parts.find((part) => part.type === "month")?.value || "";
const day = parts.find((part) => part.type === "day")?.value || "";
return `${year}-${month}-${day}`;
};
const addDaysToDateKey = (dateKey, amount) => {
const baseDate = new Date(`${dateKey}T12:00:00Z`);
if (Number.isNaN(baseDate.getTime())) {
return "";
}
baseDate.setUTCDate(baseDate.getUTCDate() + amount);
return baseDate.toISOString().slice(0, 10);
};
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
const todayKey = getBusinessTodayKey(referenceDate);
return new Set([addDaysToDateKey(todayKey, 1), addDaysToDateKey(todayKey, 2)].filter(Boolean));
};
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => {
if (!dateKey) {
return false;
}
return getAllowedDeliveryDateKeys(referenceDate).has(dateKey);
};
export const groupSlotsFromInvitation = (invitation, referenceDate = new Date()) => {
if (!invitation) { if (!invitation) {
return []; return [];
} }
@ -44,6 +84,10 @@ export const groupSlotsFromInvitation = (invitation) => {
|| deliveryDate || deliveryDate
|| ""; || "";
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) {
return null;
}
return { return {
id: `slot-${index}-${raw}`, id: `slot-${index}-${raw}`,
date: parsedDate || deliveryDate || "", date: parsedDate || deliveryDate || "",
@ -64,6 +108,10 @@ export const groupSlotsFromInvitation = (invitation) => {
return null; return null;
} }
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) {
return null;
}
return { return {
id: slotId, id: slotId,
date: slotDate, date: slotDate,
@ -112,6 +160,7 @@ export const ClientDeliveryPage = () => {
const [selectedSlotId, setSelectedSlotId] = React.useState(null); const [selectedSlotId, setSelectedSlotId] = React.useState(null);
const [selectedSlot, setSelectedSlot] = React.useState(null); const [selectedSlot, setSelectedSlot] = React.useState(null);
const [choiceSaved, setChoiceSaved] = React.useState(false); const [choiceSaved, setChoiceSaved] = React.useState(false);
const referenceDate = React.useMemo(() => new Date(), [token]);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@ -154,7 +203,7 @@ export const ClientDeliveryPage = () => {
}; };
}, [token]); }, [token]);
const slots = groupSlotsFromInvitation(invitation); const slots = groupSlotsFromInvitation(invitation, referenceDate);
const invitationState = invitation?.state || "awaiting_choice"; const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
@ -189,7 +238,12 @@ export const ClientDeliveryPage = () => {
}); });
const loadedInvitation = await fetchDeliveryInvitation(token); const loadedInvitation = await fetchDeliveryInvitation(token);
setInvitation(loadedInvitation); setInvitation(loadedInvitation);
setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot); setSelectedSlot(
buildSelectedSlotFromInvitation(
loadedInvitation,
groupSlotsFromInvitation(loadedInvitation, referenceDate),
) || effectiveSelectedSlot,
);
setChoiceSaved(true); setChoiceSaved(true);
setActionMessage("Выбор сохранен, спасибо."); setActionMessage("Выбор сохранен, спасибо.");
} catch (confirmError) { } catch (confirmError) {
@ -286,7 +340,17 @@ export const ClientDeliveryPage = () => {
<Panel className="p-5 text-sm leading-6 text-[var(--color-text-muted)] sm:p-6">{actionMessage}</Panel> <Panel className="p-5 text-sm leading-6 text-[var(--color-text-muted)] sm:p-6">{actionMessage}</Panel>
) : null} ) : null}
{!loading && error && invitation ? <DeliveryStateNotice state="default" /> : null} {!loading && error && invitation ? (
<Panel className="space-y-2 border-[rgba(204,112,0,0.28)] bg-[var(--color-surface)] p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
Не удалось сохранить
</p>
<h2 className="text-2xl font-semibold leading-tight">
Проверьте выбор еще раз
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">{error}</p>
</Panel>
) : null}
</div> </div>
</main> </main>
); );

View File

@ -13,7 +13,7 @@ describe("ClientDeliveryPage helpers", () => {
"2026-04-15, До обеда", "2026-04-15, До обеда",
"2026-04-15, После обеда", "2026-04-15, После обеда",
], ],
}), }, new Date("2026-04-14T09:00:00Z")),
).toEqual([ ).toEqual([
{ {
id: "slot-0-2026-04-15, До обеда", id: "slot-0-2026-04-15, До обеда",
@ -58,7 +58,7 @@ describe("ClientDeliveryPage helpers", () => {
"2026-04-15, До обеда", "2026-04-15, До обеда",
"2026-04-15, После обеда", "2026-04-15, После обеда",
], ],
}), }, new Date("2026-04-13T09:00:00Z")),
).toEqual([ ).toEqual([
{ {
id: "slot-0-2026-04-14, До обеда", id: "slot-0-2026-04-14, До обеда",
@ -114,7 +114,7 @@ describe("ClientDeliveryPage helpers", () => {
{ id: "slot-object", date: "2026-04-15", time: "Первая половина дня" }, { id: "slot-object", date: "2026-04-15", time: "Первая половина дня" },
42, 42,
], ],
}), }, new Date("2026-04-14T09:00:00Z")),
).toEqual([ ).toEqual([
{ {
id: "slot-object", id: "slot-object",
@ -123,4 +123,43 @@ describe("ClientDeliveryPage helpers", () => {
}, },
]); ]);
}); });
it("keeps only tomorrow and the day after tomorrow", () => {
expect(
groupSlotsFromInvitation(
{
availableSlots: [
"2026-04-15, Первая половина дня",
"2026-04-15, Вторая половина дня",
"2026-04-16, Первая половина дня",
"2026-04-16, Вторая половина дня",
"2026-04-17, Первая половина дня",
"2026-04-17, Вторая половина дня",
],
},
new Date("2026-04-14T09:00:00Z"),
),
).toEqual([
{
id: "slot-0-2026-04-15, Первая половина дня",
date: "2026-04-15",
time: "Первая половина дня",
},
{
id: "slot-1-2026-04-15, Вторая половина дня",
date: "2026-04-15",
time: "Вторая половина дня",
},
{
id: "slot-2-2026-04-16, Первая половина дня",
date: "2026-04-16",
time: "Первая половина дня",
},
{
id: "slot-3-2026-04-16, Вторая половина дня",
date: "2026-04-16",
time: "Вторая половина дня",
},
]);
});
}); });

View File

@ -15,6 +15,7 @@ const requireSupabase = () => {
}; };
const normalizeText = (value) => (value == null ? "" : String(value)).trim(); const normalizeText = (value) => (value == null ? "" : String(value)).trim();
const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]);
const normalizePhone = (value) => normalizeText(value).replace(/[\s\-()]/g, ""); const normalizePhone = (value) => normalizeText(value).replace(/[\s\-()]/g, "");
@ -69,7 +70,13 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
); );
const deliveryStatus = normalizeText(row.delivery_status) || "pending_confirmation"; const deliveryStatus = normalizeText(row.delivery_status) || "pending_confirmation";
const deliveryDate = normalizeText(row.delivery_date); const deliveryDate = normalizeText(row.delivery_date);
const deliveryTime = normalizeText(row.delivery_time); const rawDeliveryTime = normalizeText(row.delivery_time);
const rawDeliveryHalfDay = normalizeText(row.delivery_half_day);
const deliveryTime = ALLOWED_DELIVERY_TIMES.has(rawDeliveryTime)
? rawDeliveryTime
: ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay)
? rawDeliveryHalfDay
: "";
const deliveryAddress = normalizeText(row.delivery_address); const deliveryAddress = normalizeText(row.delivery_address);
return { return {
@ -114,8 +121,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
deliveryHalfDay: getOrderGroupDeliveryHalfDay({ deliveryHalfDay: getOrderGroupDeliveryHalfDay({
deliveryHalfDay: row.delivery_half_day, deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime, deliveryTime: rawDeliveryTime,
deliveryWindow: row.delivery_window, deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders, sourceOrders: row.source_orders,
}), }),
@ -126,8 +133,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhone, customerPhone,
customerDate, customerDate,
deliveryAddress, deliveryAddress,
row.delivery_half_day, rawDeliveryHalfDay,
deliveryTime, rawDeliveryTime,
row.delivery_window, row.delivery_window,
deliveryStatus, deliveryStatus,
getOrderGroupDeliveryStatusLabel(deliveryStatus), getOrderGroupDeliveryStatusLabel(deliveryStatus),
@ -135,8 +142,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
row.status, row.status,
getOrderGroupStatusLabel(row.status), getOrderGroupStatusLabel(row.status),
getOrderGroupDeliveryHalfDay({ getOrderGroupDeliveryHalfDay({
deliveryHalfDay: row.delivery_half_day, deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime, deliveryTime: rawDeliveryTime,
deliveryWindow: row.delivery_window, deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders, sourceOrders: row.source_orders,
}), }),

View File

@ -86,6 +86,26 @@ describe("mapOrderGroupRowToDeliveryGroup", () => {
expect(group.notReadyCount).toBe(0); expect(group.notReadyCount).toBe(0);
expect(group.deliveryDate).toBe(""); expect(group.deliveryDate).toBe("");
}); });
it("drops invalid delivery time values instead of showing them", () => {
const group = mapOrderGroupRowToDeliveryGroup({
id: "group-with-bad-time",
group_key: "9781632663|08.05.26",
customer_name: "Зиновьев Алексей Гаврилович",
customer_phone: "9781632663",
customer_date: "08.05.26",
delivery_time: "Зиновьев Алексей Гаврилович",
delivery_half_day: null,
order_numbers: ["СФ Т\\ЕА-26979"],
status: "ready_for_notification",
delivery_status: "pending_confirmation",
created_at: "2026-05-05 09:43:53.750061+00",
updated_at: "2026-05-05 09:43:53.750061+00",
});
expect(group.deliveryTime).toBe("");
expect(group.searchText).not.toContain("зиновьев алексей гаврилович зиновьев алексей гаврилович");
});
}); });
describe("updateOrderGroupDeliveryChoice", () => { describe("updateOrderGroupDeliveryChoice", () => {