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:
parent
633973142d
commit
7e399f2517
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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: "Вторая половина дня",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue