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,
} 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) {
return [];
}
@ -44,6 +84,10 @@ export const groupSlotsFromInvitation = (invitation) => {
|| deliveryDate
|| "";
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) {
return null;
}
return {
id: `slot-${index}-${raw}`,
date: parsedDate || deliveryDate || "",
@ -64,6 +108,10 @@ export const groupSlotsFromInvitation = (invitation) => {
return null;
}
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) {
return null;
}
return {
id: slotId,
date: slotDate,
@ -112,6 +160,7 @@ export const ClientDeliveryPage = () => {
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
const [selectedSlot, setSelectedSlot] = React.useState(null);
const [choiceSaved, setChoiceSaved] = React.useState(false);
const referenceDate = React.useMemo(() => new Date(), [token]);
React.useEffect(() => {
let cancelled = false;
@ -154,7 +203,7 @@ export const ClientDeliveryPage = () => {
};
}, [token]);
const slots = groupSlotsFromInvitation(invitation);
const slots = groupSlotsFromInvitation(invitation, referenceDate);
const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
@ -189,7 +238,12 @@ export const ClientDeliveryPage = () => {
});
const loadedInvitation = await fetchDeliveryInvitation(token);
setInvitation(loadedInvitation);
setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot);
setSelectedSlot(
buildSelectedSlotFromInvitation(
loadedInvitation,
groupSlotsFromInvitation(loadedInvitation, referenceDate),
) || effectiveSelectedSlot,
);
setChoiceSaved(true);
setActionMessage("Выбор сохранен, спасибо.");
} 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>
) : 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>
</main>
);

View File

@ -13,7 +13,7 @@ describe("ClientDeliveryPage helpers", () => {
"2026-04-15, До обеда",
"2026-04-15, После обеда",
],
}),
}, new Date("2026-04-14T09:00:00Z")),
).toEqual([
{
id: "slot-0-2026-04-15, До обеда",
@ -58,7 +58,7 @@ describe("ClientDeliveryPage helpers", () => {
"2026-04-15, До обеда",
"2026-04-15, После обеда",
],
}),
}, new Date("2026-04-13T09:00:00Z")),
).toEqual([
{
id: "slot-0-2026-04-14, До обеда",
@ -114,7 +114,7 @@ describe("ClientDeliveryPage helpers", () => {
{ id: "slot-object", date: "2026-04-15", time: "Первая половина дня" },
42,
],
}),
}, new Date("2026-04-14T09:00:00Z")),
).toEqual([
{
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 ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]);
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 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);
return {
@ -114,8 +121,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryDate,
deliveryTime,
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
deliveryHalfDay: row.delivery_half_day,
deliveryTime,
deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime: rawDeliveryTime,
deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders,
}),
@ -126,8 +133,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
customerPhone,
customerDate,
deliveryAddress,
row.delivery_half_day,
deliveryTime,
rawDeliveryHalfDay,
rawDeliveryTime,
row.delivery_window,
deliveryStatus,
getOrderGroupDeliveryStatusLabel(deliveryStatus),
@ -135,8 +142,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
row.status,
getOrderGroupStatusLabel(row.status),
getOrderGroupDeliveryHalfDay({
deliveryHalfDay: row.delivery_half_day,
deliveryTime,
deliveryHalfDay: rawDeliveryHalfDay,
deliveryTime: rawDeliveryTime,
deliveryWindow: row.delivery_window,
sourceOrders: row.source_orders,
}),

View File

@ -86,6 +86,26 @@ describe("mapOrderGroupRowToDeliveryGroup", () => {
expect(group.notReadyCount).toBe(0);
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", () => {