feat: show client order items in delivery flow
This commit is contained in:
parent
ca72a4e662
commit
c28c826601
|
|
@ -18,6 +18,36 @@ const STATE_LABELS = {
|
||||||
|
|
||||||
const DEFAULT_SLOTS = ["Первая половина дня", "Вторая половина дня"];
|
const DEFAULT_SLOTS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
|
|
||||||
|
const splitOrderItem = (item) => {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const [name, quantity] = item.split("|").map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
name: name || item.trim(),
|
||||||
|
quantity: quantity || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === "object") {
|
||||||
|
const name = typeof item.name === "string" ? item.name.trim() : typeof item.label === "string" ? item.label.trim() : "";
|
||||||
|
const quantity = typeof item.quantity === "string"
|
||||||
|
? item.quantity.trim()
|
||||||
|
: typeof item.quantity === "number"
|
||||||
|
? String(item.quantity)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name || "Позиция",
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const DeliveryChoiceFlow = ({
|
export const DeliveryChoiceFlow = ({
|
||||||
invitation = {},
|
invitation = {},
|
||||||
onConfirmChoice = () => {},
|
onConfirmChoice = () => {},
|
||||||
|
|
@ -28,6 +58,9 @@ export const DeliveryChoiceFlow = ({
|
||||||
const slots = invitation.availableSlots?.length ? invitation.availableSlots : DEFAULT_SLOTS;
|
const slots = invitation.availableSlots?.length ? invitation.availableSlots : DEFAULT_SLOTS;
|
||||||
const orderNumber = invitation.orderNumber || "—";
|
const orderNumber = invitation.orderNumber || "—";
|
||||||
const customerName = invitation.customerName || "Клиент";
|
const customerName = invitation.customerName || "Клиент";
|
||||||
|
const orderItems = (invitation.orderItems || invitation.items || [])
|
||||||
|
.map(splitOrderItem)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return <DeliveryStateNotice state={state} />;
|
return <DeliveryStateNotice state={state} />;
|
||||||
|
|
@ -36,16 +69,33 @@ export const DeliveryChoiceFlow = ({
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-5 p-5 sm:p-6">
|
<Panel className="space-y-5 p-5 sm:p-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Клиентская ссылка</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Согласование доставки</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Выберите время доставки</h1>
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Выберите время доставки</h1>
|
||||||
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Заказ {orderNumber} для {customerName}. Выберите подходящую половину дня и подтвердите выбор.
|
Заказ {orderNumber} для {customerName}. Проверьте состав заказа и выберите удобную половину дня.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{orderItems.length ? (
|
||||||
|
<div className="space-y-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||||
|
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">Состав заказа</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{orderItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.name}-${item.quantity || "item"}`}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="leading-6">{item.name}</span>
|
||||||
|
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{slots.map((slot) => (
|
{slots.map((slot) => (
|
||||||
<Button key={slot} className="w-full" onClick={() => onConfirmChoice(slot)}>
|
<Button key={slot} className="w-full" onClick={() => onConfirmChoice(slot)}>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,33 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
expect(markup).toContain("Ожидает ответа клиента");
|
expect(markup).toContain("Ожидает ответа клиента");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders order items with quantities when they are provided", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DeliveryChoiceFlow
|
||||||
|
invitation={{
|
||||||
|
state: "awaiting_choice",
|
||||||
|
orderNumber: "CD-240031",
|
||||||
|
customerName: "Мария Волкова",
|
||||||
|
orderItems: [
|
||||||
|
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
|
||||||
|
{ name: "Фурнитура Blum", quantity: "12 шт" },
|
||||||
|
{ name: "Монтажный комплект" },
|
||||||
|
],
|
||||||
|
availableSlots: ["Первая половина дня", "Вторая половина дня"],
|
||||||
|
}}
|
||||||
|
onConfirmChoice={() => {}}
|
||||||
|
onRequestNewLink={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Состав заказа");
|
||||||
|
expect(markup).toContain("Кухонный гарнитур");
|
||||||
|
expect(markup).toContain("1 комплект");
|
||||||
|
expect(markup).toContain("Фурнитура Blum");
|
||||||
|
expect(markup).toContain("12 шт");
|
||||||
|
expect(markup).toContain("Монтажный комплект");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a logistics notice when the order is transferred", () => {
|
it("renders a logistics notice when the order is transferred", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<DeliveryChoiceFlow
|
<DeliveryChoiceFlow
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ export const DeliveryStateNotice = ({ state = "default" }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-3 p-5 sm:p-6">
|
<Panel className="space-y-3 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Статус ссылки</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Статус заказа</p>
|
||||||
<h2 className="text-2xl font-semibold leading-tight">{copy.title}</h2>
|
<h2 className="text-2xl font-semibold leading-tight">{copy.title}</h2>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">{copy.description}</p>
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">{copy.description}</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -159,9 +159,9 @@ export const ClientDeliveryPage = () => {
|
||||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
<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">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||||
<Panel className="space-y-3 p-5 sm:p-6">
|
<Panel className="space-y-3 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Публичная ссылка</p>
|
<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>
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Загрузка страницы</h1>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">Подтягиваем актуальный статус заказа.</p>
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">Подтягиваем актуальные данные по заказу.</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -173,8 +173,8 @@ export const ClientDeliveryPage = () => {
|
||||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
<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">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||||
<Panel className="space-y-3 p-5 sm:p-6">
|
<Panel className="space-y-3 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Публичная ссылка</p>
|
<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>
|
<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>
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">{error}</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -188,7 +188,7 @@ export const ClientDeliveryPage = () => {
|
||||||
<main className="min-h-screen bg-[var(--color-bg)] px-3 py-4 sm:px-6 sm:py-8">
|
<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">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||||
<Panel className="space-y-3 p-5 sm:p-6">
|
<Panel className="space-y-3 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Публичная ссылка</p>
|
<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>
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
{isActiveState
|
{isActiveState
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ export const buildShowcaseInvitation = (token = SHOWCASE_TOKEN, now = new Date()
|
||||||
state: "awaiting_choice",
|
state: "awaiting_choice",
|
||||||
deliveryDate: firstDay,
|
deliveryDate: firstDay,
|
||||||
deliveryTime: "До обеда",
|
deliveryTime: "До обеда",
|
||||||
|
orderItems: [
|
||||||
|
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
|
||||||
|
{ name: "Фурнитура Blum", quantity: "12 шт" },
|
||||||
|
{ name: "Монтажный комплект", quantity: "1 набор" },
|
||||||
|
],
|
||||||
availableSlots: [
|
availableSlots: [
|
||||||
`${firstDay}, До обеда`,
|
`${firstDay}, До обеда`,
|
||||||
`${firstDay}, После обеда`,
|
`${firstDay}, После обеда`,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,16 @@ describe("deliveryInvitationApi", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes readable order items in the showcase invitation", () => {
|
||||||
|
const invitation = buildShowcaseInvitation("showcase", new Date("2026-04-14T09:00:00Z"));
|
||||||
|
|
||||||
|
expect(invitation.orderItems).toEqual([
|
||||||
|
{ name: "Кухонный гарнитур", quantity: "1 комплект" },
|
||||||
|
{ name: "Фурнитура Blum", quantity: "12 шт" },
|
||||||
|
{ name: "Монтажный комплект", quantity: "1 набор" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("confirms a delivery choice with the chosen slot", async () => {
|
it("confirms a delivery choice with the chosen slot", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
invoke.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,50 @@ import {
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
|
|
||||||
|
const normalizeOrderItems = (
|
||||||
|
items: unknown,
|
||||||
|
): Array<{ name: string; quantity?: string }> => {
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const [namePart, quantityPart] = item.split("|").map((part) => part.trim());
|
||||||
|
const name = namePart || item.trim();
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return quantityPart ? { name, quantity: quantityPart } : { name };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const typedItem = item as { name?: unknown; quantity?: unknown; label?: unknown };
|
||||||
|
const name = typeof typedItem.name === "string"
|
||||||
|
? typedItem.name.trim()
|
||||||
|
: typeof typedItem.label === "string"
|
||||||
|
? typedItem.label.trim()
|
||||||
|
: "";
|
||||||
|
const quantity = typeof typedItem.quantity === "string"
|
||||||
|
? typedItem.quantity.trim()
|
||||||
|
: typeof typedItem.quantity === "number"
|
||||||
|
? String(typedItem.quantity)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!name && !quantity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return quantity ? { name: name || "Позиция", quantity } : { name: name || "Позиция" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((item): item is { name: string; quantity?: string } => Boolean(item));
|
||||||
|
};
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
|
@ -97,6 +141,7 @@ Deno.serve(async (request) => {
|
||||||
orderNumber: order.order_number,
|
orderNumber: order.order_number,
|
||||||
customerName: order.customer?.name || invitation.customer_name || null,
|
customerName: order.customer?.name || invitation.customer_name || null,
|
||||||
customerPhone: order.customer?.phone || invitation.customer_phone || null,
|
customerPhone: order.customer?.phone || invitation.customer_phone || null,
|
||||||
|
orderItems: normalizeOrderItems(order.customer?.items),
|
||||||
availableSlots: invitation.available_slots || [],
|
availableSlots: invitation.available_slots || [],
|
||||||
deliveryDate: invitation.delivery_date || null,
|
deliveryDate: invitation.delivery_date || null,
|
||||||
deliveryTime: invitation.delivery_time || null,
|
deliveryTime: invitation.delivery_time || null,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue