From 7e399f25170cc009acf5cae06eaa4ce4382c95f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 13 May 2026 16:17:33 +0300 Subject: [PATCH] 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. --- src/pages/ClientDeliveryPage.jsx | 72 +++++++++++++++++-- src/pages/ClientDeliveryPage.test.js | 45 +++++++++++- src/services/supabase/orderGroupRepository.js | 21 ++++-- .../supabase/orderGroupRepository.test.js | 20 ++++++ 4 files changed, 144 insertions(+), 14 deletions(-) diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 883f492..9140cf5 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -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 = () => { {actionMessage} ) : null} - {!loading && error && invitation ? : null} + {!loading && error && invitation ? ( + +

+ Не удалось сохранить +

+

+ Проверьте выбор еще раз +

+

{error}

+
+ ) : null} ); diff --git a/src/pages/ClientDeliveryPage.test.js b/src/pages/ClientDeliveryPage.test.js index bdd818e..401e517 100644 --- a/src/pages/ClientDeliveryPage.test.js +++ b/src/pages/ClientDeliveryPage.test.js @@ -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: "Вторая половина дня", + }, + ]); + }); }); diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index ee486d3..2a26538 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -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, }), diff --git a/src/services/supabase/orderGroupRepository.test.js b/src/services/supabase/orderGroupRepository.test.js index 274e095..2ded6f8 100644 --- a/src/services/supabase/orderGroupRepository.test.js +++ b/src/services/supabase/orderGroupRepository.test.js @@ -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", () => {