supersam/src/pages/ClientDeliveryPage.jsx

556 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import { useParams } from "react-router-dom";
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
import { Panel } from "../components/UI/Panel";
import { Skeleton } from "../components/UI/Loading";
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
import {
confirmDeliveryChoice,
fetchDeliveryInvitation,
} from "../services/deliveryInvitationApi";
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 isSundayKey = (dateKey) => {
if (!dateKey) return true;
const d = new Date(`${dateKey}T12:00:00Z`);
return d.getUTCDay() === 0;
};
const getNextWorkdayKey = (dateKey) => {
let next = addDaysToDateKey(dateKey, 1);
while (isSundayKey(next)) {
next = addDaysToDateKey(next, 1);
}
return next;
};
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
const todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = getNextWorkdayKey(todayKey);
const secondWorkday = getNextWorkdayKey(firstWorkday);
return new Set([firstWorkday, secondWorkday].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 [];
}
const rawSlots = Array.isArray(invitation.availableSlots) ? invitation.availableSlots : [];
const deliveryDate = invitation.deliveryDate;
const deliveryTime = invitation.deliveryTime;
if (!rawSlots.length && !deliveryDate) {
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday)
const todayKey = getBusinessTodayKey(referenceDate);
const firstWorkday = getNextWorkdayKey(todayKey);
const secondWorkday = getNextWorkdayKey(firstWorkday);
return [
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
{ id: `slot-${secondWorkday}-first`, date: secondWorkday, time: "Первая половина дня" },
{ id: `slot-${secondWorkday}-second`, date: secondWorkday, time: "Вторая половина дня" },
].filter((s) => s.date);
}
if (!rawSlots.length && deliveryDate) {
return [
{
id: `slot-${deliveryDate}-${deliveryTime || "default"}`,
date: deliveryDate,
time: deliveryTime || "Половина дня",
},
];
}
return rawSlots
.map((raw, index) => {
if (typeof raw === "string") {
const parts = raw.split(",");
const datePart = parts[0]?.trim() || "";
const timePart = parts.slice(1).join(",").trim() || "";
const parsedDate = datePart.replace(/[а-яё]+/gi, "").trim()
|| deliveryDate
|| "";
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) {
return null;
}
return {
id: `slot-${index}-${raw}`,
date: parsedDate || deliveryDate || "",
time: timePart || deliveryTime || raw,
};
}
if (raw && typeof raw === "object") {
const slotId = typeof raw.id === "string" ? raw.id : `slot-${index}-${deliveryDate || "custom"}`;
const slotDate = typeof raw.date === "string" ? raw.date : deliveryDate || "";
const slotTime = typeof raw.time === "string"
? raw.time
: typeof raw.label === "string"
? raw.label
: deliveryTime || "";
if (!slotDate && !slotTime) {
return null;
}
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) {
return null;
}
return {
id: slotId,
date: slotDate,
time: slotTime,
};
}
return null;
})
.filter(Boolean);
};
export const buildDeliveryConfirmationPayload = ({
slot,
invitation,
searchDate,
deliveryType = "delivery",
pickupDate,
pickupTimeSlot,
}) => {
if (deliveryType === "pickup") {
return {
deliveryType: "pickup",
pickupDate: pickupDate || slot?.date || undefined,
pickupTimeSlot: pickupTimeSlot || slot?.time || undefined,
deliveryDate: pickupDate || slot?.date || searchDate || invitation?.deliveryDate || undefined,
deliveryTime: pickupTimeSlot || slot?.time || undefined,
};
}
return {
deliveryType: "delivery",
deliveryDate: slot?.date || searchDate || invitation?.deliveryDate || 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 getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) => {
if (isChoiceSaved) {
return "";
}
return isActiveState
? "Вам предложены варианты доставки. Выберите удобную дату и время."
: "По этому заказу согласование доставки завершено или передано логисту.";
};
const TAB_DELIVERY = "delivery";
const TAB_PICKUP = "pickup";
export const ClientDeliveryPage = () => {
const { token } = useParams();
const [invitation, setInvitation] = React.useState(null);
const [loading, setLoading] = React.useState(Boolean(token));
const [error, setError] = React.useState("");
const [actionMessage, setActionMessage] = React.useState("");
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
const [selectedSlot, setSelectedSlot] = React.useState(null);
const [choiceSaved, setChoiceSaved] = React.useState(false);
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
const [deliveryAddress, setDeliveryAddress] = React.useState("");
const referenceDate = React.useMemo(
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
[token, invitation?.smsSentAt],
);
React.useEffect(() => {
let cancelled = false;
const loadInvitation = async () => {
if (!token) {
setLoading(false);
setError("Не передан токен приглашения.");
return;
}
setLoading(true);
setError("");
setActionMessage("");
setSelectedSlotId(null);
setSelectedSlot(null);
setChoiceSaved(false);
try {
const loadedInvitation = await fetchDeliveryInvitation(token);
if (!cancelled) {
setInvitation(loadedInvitation);
// If invitation already has deliveryType=pickup, pre-select pickup tab
if (loadedInvitation?.deliveryType === "pickup") {
setActiveTab(TAB_PICKUP);
}
}
} catch (fetchError) {
if (!cancelled) {
setInvitation(null);
setError(fetchError instanceof Error ? fetchError.message : "Не удалось загрузить приглашение");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadInvitation();
return () => {
cancelled = true;
};
}, [token]);
const slots = groupSlotsFromInvitation(invitation, referenceDate);
const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
const invitationSelectedSlot = isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots);
const effectiveSelectedSlot = selectedSlot || invitationSelectedSlot;
const isChoiceSaved = choiceSaved || (!isActiveState && Boolean(invitationSelectedSlot));
const savedChoiceLabel = effectiveSelectedSlot
? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}`
: "";
const heroDescription = getClientDeliveryHeroDescription(isActiveState, isChoiceSaved);
const handleSaveChoice = async () => {
if (!token) {
return;
}
if (!effectiveSelectedSlot) {
setError("Сначала выберите дату и половину дня.");
return;
}
setActionMessage("Сохраняем выбор...");
setChoiceSaved(false);
setError("");
try {
await confirmDeliveryChoice({
token,
deliveryTime: effectiveSelectedSlot.time,
deliveryDate: effectiveSelectedSlot.date,
deliveryType: activeTab,
...(activeTab === TAB_PICKUP ? {
pickupDate: effectiveSelectedSlot.date,
pickupTimeSlot: effectiveSelectedSlot.time,
} : {}),
...(activeTab === TAB_DELIVERY && deliveryAddress.trim() ? {
deliveryAddress: deliveryAddress.trim(),
} : {}),
});
const loadedInvitation = await fetchDeliveryInvitation(token);
setInvitation(loadedInvitation);
setSelectedSlot(
buildSelectedSlotFromInvitation(
loadedInvitation,
groupSlotsFromInvitation(loadedInvitation, referenceDate),
) || effectiveSelectedSlot,
);
setChoiceSaved(true);
setActionMessage("Выбор сохранен, спасибо.");
} catch (confirmError) {
setActionMessage("");
setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор");
}
};
const handleSlotSelect = (slot) => {
setSelectedSlotId(slot.id);
setSelectedSlot(slot);
setChoiceSaved(false);
setActionMessage(
`Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`,
);
setError("");
};
if (loading) {
return (
<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">
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Загрузка страницы</h1>
<div className="space-y-2 mt-4">
<Skeleton className="w-full" />
<Skeleton className="w-3/4" />
<Skeleton className="w-1/2" />
</div>
</Panel>
<Panel className="p-5 sm:p-6">
<div className="space-y-3">
<Skeleton variant="heading" className="w-2/3" />
<Skeleton className="w-full" />
<Skeleton className="w-1/2" />
</div>
</Panel>
</div>
</main>
);
}
if (error && !invitation) {
return (
<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">
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Не удалось открыть страницу</h1>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">{error}</p>
</Panel>
</div>
</main>
);
}
return (
<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">
{!isChoiceSaved ? (
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
{heroDescription ? (
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{heroDescription}
</p>
) : null}
</Panel>
) : null}
<OrderCompositionPanel invitation={invitation} />
{isChoiceSaved && savedChoiceLabel ? (
<Panel className="space-y-2 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
<h2 className="text-xl font-semibold leading-tight">
{invitation?.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}: {savedChoiceLabel}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
{getInvitationReferenceLabel(invitation)}
</p>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
</p>
{(invitation?.pickupCode || invitation?.pickup_code) ? (
<div className="mt-3 rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
</div>
) : null}
</Panel>
) : null}
{isActiveState && !isChoiceSaved ? (
<>
{/* Tab switcher */}
<div className="flex gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
<button
type="button"
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
activeTab === TAB_DELIVERY
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => {
setActiveTab(TAB_DELIVERY);
setSelectedSlotId(null);
setSelectedSlot(null);
setActionMessage("");
}}
>
🚚 Доставка
</button>
<button
type="button"
className={`flex-1 rounded-[24px] px-4 py-2.5 text-sm font-semibold transition ${
activeTab === TAB_PICKUP
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => {
setActiveTab(TAB_PICKUP);
setSelectedSlotId(null);
setSelectedSlot(null);
setActionMessage("");
}}
>
🏪 Самовывоз
</button>
</div>
{activeTab === TAB_DELIVERY && !invitation?.deliveryAddress && !invitation?.customerAddress && (
<Panel className="space-y-3 border-[rgba(239,68,68,0.3)] bg-[var(--color-surface)] p-5 sm:p-6">
<div className="flex items-start gap-3">
<span className="text-xl">📍</span>
<div className="flex-1 space-y-2">
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">Укажите адрес доставки</p>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Адрес доставки отсутствует в заказе. Пожалуйста, введите полный адрес, куда нужно привезти заказ.
</p>
</div>
</div>
<input
type="text"
value={deliveryAddress}
onChange={(e) => setDeliveryAddress(e.target.value)}
placeholder="Город, улица, дом, квартира"
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-4 py-3 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</Panel>
)}
{activeTab === TAB_DELIVERY && !invitation?.isPayedShip ? (
<div className="rounded-2xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4">
<div className="flex items-start gap-3">
<span className="text-lg">💰</span>
<div>
<p className="text-sm font-semibold text-[var(--color-warning)]">
Стоимость доставки не оплачена
</p>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Стоимость доставки будет рассчитана отдельно и согласована с логистом после выбора даты и времени.
</p>
</div>
</div>
</div>
) : null}
{activeTab === TAB_DELIVERY && slots.length ? (
<DeliverySlotsPicker
slots={slots}
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
/>
) : null}
{activeTab === TAB_PICKUP ? (
<PickupSlotsPicker
onSelectSlot={handleSlotSelect}
selectedSlotId={selectedSlotId}
referenceDate={referenceDate}
/>
) : null}
{activeTab === TAB_PICKUP && (invitation?.pickupCode || invitation?.pickup_code) ? (
<div className="rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
</div>
) : null}
{activeTab === TAB_DELIVERY && !slots.length ? (
<Panel className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-text-muted)]">Нет доступных слотов для выбора доставки.</p>
</Panel>
) : null}
</>
) : null}
{isActiveState && !isChoiceSaved ? (
<DeliveryChoiceFlow
invitation={invitation}
selectedSlot={effectiveSelectedSlot}
onConfirmChoice={handleSaveChoice}
deliveryType={activeTab}
/>
) : !isActiveState && !isChoiceSaved ? (
<DeliveryStateNotice state={invitationState} />
) : null}
{actionMessage ? (
<Panel className="p-5 text-sm leading-6 text-[var(--color-text-muted)] sm:p-6">{actionMessage}</Panel>
) : 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>
);
};