feat: show client order items in delivery flow

This commit is contained in:
Codex 2026-04-16 12:42:41 +03:00
parent ca72a4e662
commit c28c826601
7 changed files with 147 additions and 10 deletions

View File

@ -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)}>

View File

@ -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

View File

@ -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>
); );
}; };

View File

@ -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

View File

@ -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}, После обеда`,

View File

@ -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: {

View File

@ -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,