556 lines
22 KiB
JavaScript
556 lines
22 KiB
JavaScript
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>
|
||
);
|
||
}; |