feat(client): test consent page + 'test notification' button on /client/test

This commit is contained in:
Hermes 2026-08-13 14:17:18 +02:00
parent 045c903074
commit 663b31b3df
3 changed files with 279 additions and 1 deletions

View File

@ -0,0 +1,204 @@
import React, { useState } from "react";
import { Panel } from "../components/UI/Panel";
import { Button } from "../components/UI/Button";
import { formatDeliveryDate, getDeliveryRelativeDayLabel } from "../components/client/deliveryDateFormatting";
const TAB_DELIVERY = "delivery";
const TAB_PICKUP = "pickup";
const MOCK_PRODUCTS = [
{ name: "Цемент М500, 50 кг", quantity: "10", unit: "меш" },
{ name: "Песок строительный", quantity: "2", unit: "м³" },
{ name: "Кирпич рядовой", quantity: "400", unit: "шт" },
{ name: "Арматура 12 мм", quantity: "60", unit: "м" },
];
const buildMockSlots = () => {
const day = (offset) => {
const d = new Date();
d.setDate(d.getDate() + offset);
return d.toISOString().slice(0, 10);
};
const skipSunday = (dateStr) => {
const d = new Date(`${dateStr}T12:00:00Z`);
if (d.getUTCDay() === 0) {
d.setUTCDate(d.getUTCDate() + 1);
}
return d.toISOString().slice(0, 10);
};
const d1 = skipSunday(day(1));
const d2 = skipSunday(day(2));
return [
{ id: `slot-${d1}-first`, date: d1, time: "Первая половина дня" },
{ id: `slot-${d1}-second`, date: d1, time: "Вторая половина дня" },
{ id: `slot-${d2}-first`, date: d2, time: "Первая половина дня" },
{ id: `slot-${d2}-second`, date: d2, time: "Вторая половина дня" },
];
};
const groupSlotsByDate = (slots) => {
const groups = new Map();
for (const slot of slots) {
if (!groups.has(slot.date)) groups.set(slot.date, []);
groups.get(slot.date).push(slot);
}
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b));
};
const SlotGroupHeading = ({ dateStr }) => {
const relative = getDeliveryRelativeDayLabel(dateStr);
const formatted = formatDeliveryDate(dateStr);
return relative
? `Доставка ${relative.charAt(0).toLowerCase()}${relative.slice(1)} · ${formatted}`
: `Доставка ${formatted}`;
};
export const ClientTestConsentPage = () => {
const [activeTab, setActiveTab] = useState(TAB_DELIVERY);
const [selectedSlot, setSelectedSlot] = useState(null);
const [saved, setSaved] = useState(false);
const slots = buildMockSlots();
const grouped = groupSlotsByDate(slots);
const typeLabel = activeTab === TAB_PICKUP ? "самовывоз" : "доставку";
const handleSave = () => {
if (!selectedSlot) return;
setSaved(true);
};
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">
{activeTab === TAB_PICKUP ? "Выберите время самовывоза" : "Выберите время доставки"}
</h1>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Это тестовая страница данные ненастоящие. Выберите удобную половину дня для {typeLabel}.
</p>
</Panel>
<Panel className="space-y-3 p-5 sm:p-6">
<div className="flex items-center gap-3">
<img src="/icons/icon-192.png" alt="" className="h-12 w-12 rounded-xl" />
<div>
<p className="text-sm text-[var(--color-text-muted)]">Заказ TEST-001 · счёт 1</p>
<p className="text-base font-semibold text-[var(--color-text)]">Тестов Тест Тестович</p>
<p className="text-sm text-[var(--color-text-muted)]">
{activeTab === TAB_PICKUP
? "Самовывоз со склада"
: "г. Симферополь, ул. Тестовая, д. 1"}
</p>
</div>
</div>
<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); setSelectedSlot(null); setSaved(false); }}
>
🚚 Доставка
</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); setSelectedSlot(null); setSaved(false); }}
>
🏪 Самовывоз
</button>
</div>
</Panel>
<Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm font-semibold text-[var(--color-text)]">Состав заказа</p>
<div className="space-y-2">
{MOCK_PRODUCTS.map((p, idx) => (
<div
key={idx}
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">{p.name}</span>
<span className="flex-shrink-0 rounded-full bg-[var(--color-accent-soft)] px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">
{p.quantity} {p.unit}
</span>
</div>
))}
</div>
</Panel>
{saved && selectedSlot ? (
<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">
{activeTab === TAB_PICKUP ? "Самовывоз" : "Доставка"}: {formatDeliveryDate(selectedSlot.date)} / {selectedSlot.time}
</h2>
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Выбор сохранен (тестовая страница в реальном заказе здесь будет подтверждение от менеджера).
</p>
</Panel>
) : (
<>
{grouped.map(([date, dateSlots]) => (
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
<summary className="cursor-pointer list-none p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<h4 className="font-medium"><SlotGroupHeading dateStr={date} /></h4>
<span className="text-sm text-[var(--color-text-muted)] group-open:hidden">Раскрыть</span>
<span className="hidden text-sm text-[var(--color-text-muted)] group-open:inline">Свернуть</span>
</div>
</summary>
<div className="px-5 pb-5 sm:px-6 sm:pb-6">
<div className="grid gap-3 sm:grid-cols-2">
{dateSlots.map((slot) => {
const isSelected = selectedSlot?.id === slot.id;
return (
<Button
key={slot.id}
variant={isSelected ? "primary" : "secondary"}
aria-pressed={isSelected}
onClick={() => { setSelectedSlot(slot); setSaved(false); }}
>
{slot.time}
{isSelected ? " — Выбрано" : ""}
</Button>
);
})}
</div>
</div>
</details>
))}
<div className="flex flex-col gap-3 sm:flex-row">
<Button
className="w-full sm:w-auto"
disabled={!selectedSlot}
onClick={handleSave}
>
Сохранить
</Button>
</div>
</>
)}
</div>
</main>
);
};
export default ClientTestConsentPage;

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { Panel } from "../components/UI/Panel";
import { PwaInstallCard } from "../components/client/PwaInstallCard";
import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner";
@ -8,6 +8,10 @@ import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBan
* Mock data no real order, no API calls.
* Route: /client/test
*/
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || "";
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
const MOCK_INVITATION = {
customerName: "Тестов Тест Тестович",
customerPhone: "79990000000",
@ -20,7 +24,42 @@ const MOCK_INVITATION = {
orderGroupId: "test-group-001",
};
const sendTestPush = async (phone) => {
const resp = await fetch(`${SUPABASE_URL}/functions/v1/send-test-push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(SUPABASE_ANON_KEY ? { apikey: SUPABASE_ANON_KEY } : {}),
},
body: JSON.stringify({
phone_normalized: phone,
title: "СуперСам — согласуйте доставку",
body: "Ваш заказ готов к доставке. Выберите удобное время.",
url: `${window.location.origin}/client/test-consent`,
}),
});
const data = await resp.json().catch(() => ({}));
return { ok: resp.ok, ...data };
};
export const ClientTestPage = () => {
const [sending, setSending] = useState(false);
const [sendResult, setSendResult] = useState(null); // null | {ok, sent, error}
const handleTestPush = async () => {
setSending(true);
setSendResult(null);
try {
const phone = window.localStorage.getItem("supersam_phone") || MOCK_INVITATION.customerPhone;
const result = await sendTestPush(phone);
setSendResult(result);
} catch (e) {
setSendResult({ ok: false, error: e instanceof Error ? e.message : "Ошибка отправки" });
} finally {
setSending(false);
}
};
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">
@ -60,6 +99,36 @@ export const ClientTestPage = () => {
orderGroupId={MOCK_INVITATION.orderGroupId}
/>
<Panel className="space-y-3 border border-dashed border-[var(--color-accent)] bg-[var(--color-surface)] p-5 sm:p-6">
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">🧪 Тест уведомления</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1 leading-5">
Отправить тестовое push-уведомление на этот телефон. Нажмите на уведомление
откроется тестовая страница согласования доставки.
</p>
</div>
<button
type="button"
onClick={handleTestPush}
disabled={sending}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
>
{sending ? "Отправляем…" : "Отправить тестовое уведомление"}
</button>
{sendResult?.ok ? (
<p className="text-sm text-[var(--color-accent)]">
Отправлено: {sendResult.sent} уведомление(й). Проверьте телефон.
</p>
) : null}
{sendResult && !sendResult.ok ? (
<p className="text-sm text-[var(--color-danger)]">
{sendResult.error || "Не удалось отправить уведомление"}
</p>
) : null}
</Panel>
</div>
</main>
);

View File

@ -3,6 +3,7 @@ import { Navigate, createBrowserRouter, useParams } from "react-router-dom";
import App from "./App";
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
import { ClientTestPage } from "./pages/ClientTestPage";
import { ClientTestConsentPage } from "./pages/ClientTestConsentPage";
import { DashboardPage } from "./pages/DashboardPage";
import { GroupDetailPage } from "./pages/GroupDetailPage";
import { LoginPage } from "./pages/LoginPage";
@ -56,6 +57,10 @@ export const router = createBrowserRouter([
path: "client/test",
element: <ClientTestPage />,
},
{
path: "client/test-consent",
element: <ClientTestConsentPage />,
},
{
path: "delivery/:token",
element: <LegacyDeliveryRedirect />,